From One Multiplication to a Whole Network: Operators in AI

从一个乘法到整个神经网络:AI 中的算子

算子到底是什么:从 2 × 3 开始,经过矩阵乘法、激活函数、卷积与注意力,一直讲到 GPU kernel、FlashAttention 与虚拟细胞;同时区分数学定义、框架实现与硬件实现这三层,并给出判断“新算子”是否真的新的问题清单。
Tooling
Author

Jie Hua

Published

Oct 29, 2026

写作说明 / Note

本文由 AI(DeepSeek-Flash) 检索、整理并撰写,尚未经过人工逐条校对。文中的数学推导与数值例子都可以用文中的 R 代码复现;论文引用以 arXiv 编号为主,正式发表信息请以期刊页面为准。任何”更好的算子”的说法,都应当用文中第 5、6 章的标准去检验。

This article was researched and written by an AI system (DeepSeek-Flash) and has not been proofread line by line by a human. Every derivation and numeric example can be reproduced with the R code in this post; citations lean on arXiv identifiers, so check the journal record before citing. Any claim about a “better operator” should be tested against the criteria in sections 5 and 6.

写一篇中英逐段对照的长文,向有生物学科研背景、懂基本数学但没有系统学过深度学习的读者解释”AI 算子”。从 2 × 3 讲起,区分数学上的运算、框架里的算子、GPU 上的 kernel;说明一次计算如何变成神经元、层、模块与架构;系统介绍矩阵乘法、激活、Softmax、归一化、卷积、注意力、图聚合与消息传递;比较 CNN、Transformer、GNN、RGAT 的核心计算思想,并说明 JEPA 为什么不在同一层级;把”算子更好”拆成数学机制、底层实现、整体设计三类,用 FLOPs、显存、带宽、算术强度讲清楚;最后讨论如何从零设计一个算子、如何验证,以及虚拟细胞需要什么样的算子。所有数字必须来自实际计算,图为程序化生成的 ggplot。

Write a bilingual, paragraph-by-paragraph long-form explainer on what an AI operator is. Start from 2 × 3 and separate three layers: the mathematical operation, the framework op, and the GPU kernel. Explain how one computation becomes a neuron, a layer, a module and an architecture. Cover matmul, activations, softmax, normalization, convolution, attention, graph aggregation and message passing. Compare CNN, Transformer, GNN and RGAT by their core computational idea, and explain why JEPA sits at a different level. Split “a better operator” into mathematical mechanism, low-level implementation and whole-system design, using FLOPs, memory, bandwidth and arithmetic intensity. End with how to design and validate a new operator, and what virtual-cell models need. Every number must come from code that actually runs.

1. 从最简单的计算开始:到底什么是算子? / Starting from the simplest computation

1.1 一个乘法里藏着什么 / What is inside a multiplication

\[2 \times 3 = 6\]

这个式子里其实有三样东西:两个输入(\(2\) 与 \(3\))、一个输出(\(6\)),以及把输入变成输出的那条规则。乘法是定义在两个数上的函数 \(f(a,b)=ab\),它有两个特点:接受多个输入、返回一个新值;而且可以层层嵌套,\((2\times 3)+1=7\) 又是一个新的计算。

There are three things in that line: two inputs, one output, and the rule that turns inputs into the output. Multiplication is a function of two numbers, \(f(a,b)=ab\), with two properties that matter here: it takes several inputs and returns a new value, and it nests — \((2\times 3)+1=7\) is another computation built on top of it.

数学里的 operator(算子)比”函数”更具体一点。它最早指的是作用在函数上的东西:微分算子 \(D\) 把 \(f(x)\) 变成 \(f'(x)\),积分算子把 \(f\) 变成 \(\int f\)。到了有限维,算子就退化成矩阵:\(y = A x\) 把一个向量变成另一个向量。所以”算子”在数学里一直有两种气质:它是映射,而且是有结构、可以被代数运算的映射——算子可以相加、复合、求逆。

In mathematics an operator is a little more specific than a function. It originally meant something that acts on functions: the derivative operator \(D\) sends \(f(x)\) to \(f'(x)\), an integral operator sends \(f\) to \(\int f\). In finite dimensions operators collapse into matrices: \(y = Ax\) maps one vector to another. So “operator” has always carried two flavours: it is a map, and a map with structure that can be added, composed and inverted.

深度学习借用了这个词,但把它放宽了。在 PyTorch 里,一个 operator 可以是矩阵乘法,也可以是一个逐元素的开方、一次 softmax、一次卷积、一次索引查表。共同点不是”线性”,而是三件事:输入张量、输出张量、以及一个能被自动微分使用的局部导数。

Deep learning borrowed the word and loosened it. In PyTorch an operator can be a matrix multiplication, an elementwise square root, a softmax, a convolution, a table lookup. What they share is not linearity but three things: an input tensor, an output tensor, and a local derivative that automatic differentiation can use.

1.2 同一个词,三个层次 / One word, three layers

初学者最容易混淆的地方,是把”算子”当成一个东西。实际上它至少在三个层次上被使用,而且这三层的”更好”完全是不同的意思。

The usual confusion is treating “operator” as one thing. It is used at at least three levels, and “better” means something different at each.

层次 / Layer 它是什么 / What it is 一个具体例子 / Example
数学 / Mathematics 一个映射 \(f:\mathbb{R}^n\to\mathbb{R}^m\),通常带参数 \(\theta\) \(y = \sigma(Wx+b)\)
框架 / Framework 注册在自动微分系统里的一个 op torch.nn.functional.linear → aten::addmm
硬件 / Hardware 一段真正跑在芯片上的 kernel cuBLAS 的 SGEMM、oneDNN 的卷积实现

数学层关心的是这个映射有什么性质:是否线性、是否等变、是否可逆、表达力够不够。框架层关心的是它能不能被组合和求导:前向怎么算、反向公式是什么、支不支持批量和高阶导。硬件层关心的是它在具体芯片上跑多快:访存几次、能不能并行、算术强度够不够喂满计算单元。

The mathematical layer asks what properties the map has: linear, equivariant, invertible, expressive enough. The framework layer asks whether it can be composed and differentiated: how the forward pass runs, what the backward formula is, whether it supports batching and higher-order gradients. The hardware layer asks how fast it runs on a given chip: how many memory trips, how parallel it is, whether arithmetic intensity can keep the compute units fed.

同一件事在三个层次上有三个名字:数学上写 \(AB\),框架里是 torch.matmul(底层落到 aten::mm),硬件上是 BLAS 里的一次 SGEMM。说”我发明了一个新算子”,必须先说清楚是哪一层的新。

The same act has three names: mathematically \(AB\), in the framework torch.matmul (dispatching to aten::mm), on the hardware a BLAS SGEMM call. Anyone claiming a new operator should first say which layer is new.

1.3 从标量到张量 / From scalars to tensors

把”一个数”换成”一列数”,乘法就变成了内积;换成一个二维数组,就变成了矩阵乘法;再往上加维度,就是张量运算。形状每升一维,能表达的结构就多一层,但计算量也涨得很快。

Replace “one number” by “a column of numbers” and multiplication becomes a dot product; by a 2-D array and it becomes a matrix product; keep going and you have tensor algebra. Each extra dimension buys one more kind of structure, and costs more computation.

Code
m <- matrix(c(1, 2, 3,
              0, 5, 1,
              2, 1, 4), nrow = 3, byrow = TRUE)

p1 <- ggplot() +
  geom_tile(data = data.frame(x = 1, y = 1), aes(x, y), width = 0.9, height = 0.9,
            fill = op_pal[["pale"]], colour = op_pal[["blue"]], linewidth = 0.6) +
  geom_text(data = data.frame(x = 1, y = 1), aes(x, y), label = "3", size = 5) +
  coord_equal(xlim = c(0.4, 1.6), ylim = c(0.4, 1.6)) +
  labs(title = "scalar · 0-d") + plain

p2 <- ggplot(data.frame(x = 1:4, y = 1, v = c(1.5, 0.5, 2.0, -0.5))) +
  geom_tile(aes(x, y, fill = v), width = 0.9, height = 0.9, colour = "white") +
  geom_text(aes(x, y, label = v), size = 3.6) +
  scale_fill_gradient(low = op_pal[["pale"]], high = op_pal[["teal"]], guide = "none") +
  coord_equal(xlim = c(0.4, 4.6), ylim = c(0.4, 1.6)) +
  labs(title = "vector · 4") + plain

mat <- expand.grid(i = 1:3, j = 1:3)
mat$v <- m[cbind(mat$i, mat$j)]
p3 <- ggplot(mat) +
  geom_tile(aes(j, 4 - i, fill = v), width = 0.92, height = 0.92, colour = "white") +
  geom_text(aes(j, 4 - i, label = v), size = 3.4) +
  scale_fill_gradient(low = op_pal[["pale"]], high = op_pal[["blue"]], guide = "none") +
  coord_equal(xlim = c(0.4, 3.6), ylim = c(0.4, 3.6)) +
  labs(title = "matrix · 3 × 3") + plain

mk_slice <- function(k, xoff) {
  d <- expand.grid(row = 1:3, col = 1:3)
  d$k <- k
  d$x <- d$col + xoff
  d$y <- 4 - d$row
  d$v <- (d$row + d$col + k) %% 5
  d
}
ten <- rbind(mk_slice(1, 0), mk_slice(2, 3.5))

p4 <- ggplot(ten) +
  geom_tile(aes(x, y, fill = v), width = 0.9, height = 0.9, colour = "white") +
  scale_fill_gradient(low = op_pal[["pale"]], high = op_pal[["blue"]], guide = "none") +
  annotate("text", x = 2, y = 0.45, label = "k = 1", size = 3, colour = op_pal[["grey"]]) +
  annotate("text", x = 5.5, y = 0.45, label = "k = 2", size = 3, colour = op_pal[["grey"]]) +
  annotate("segment", x = 3.75, xend = 4.25, y = 2.5, yend = 2.5,
           colour = op_pal[["grey"]], linewidth = 0.4, arrow = arrow_op) +
  coord_equal(xlim = c(0.4, 7.1), ylim = c(0.2, 4.0)) +
  labs(title = "tensor · 2 × 3 × 3") + plain

(p1 | p2) / (p3 | p4)
Figure 1: 标量、向量、矩阵与三阶张量。颜色深浅表示数值大小;张量的两个切片沿第三维错开。 | Scalar, vector, matrix and a third-order tensor. Shading encodes magnitude; the two slices of the tensor are offset along the third axis.

计算量的增长速度值得单独记住。两个 \(n\times n\) 矩阵相乘需要 \(2n^3\) 次浮点运算:\(n=1000\) 时约 \(2\times 10^{9}\) 次,已经相当于一次中等规模的科学计算;\(n=4096\) 时是 \(1.4\times 10^{11}\) 次。这就是为什么”矩阵乘法”这一个算子值得被单独优化几十年——它是绝大多数模型的主要开销。

The growth is worth memorising. Multiplying two \(n\times n\) matrices costs \(2n^3\) floating-point operations: about \(2\times 10^{9}\) at \(n=1000\), already a respectable scientific computation, and \(1.4\times 10^{11}\) at \(n=4096\). That is why one operator, matrix multiplication, has been optimised for decades: it dominates the cost of most models.

1.4 为什么算子必须”知道自己怎么求导” / Why an operator must know its own derivative

深度学习和传统数值计算最大的差别,不是算得快,而是每一步计算都记着自己的局部导数。这就是计算图(computation graph):每个节点是一个算子,每条边是一条数据流;反向传播时,梯度沿着边的反方向走,每个算子只需要把”上游给的梯度”乘上”自己的局部导数”。

The biggest difference from classical numerics is not speed but that every step remembers its local derivative. That is the computation graph: each node is an operator, each edge a data flow; backpropagation walks the edges backwards, and each operator only has to multiply the incoming gradient by its own local derivative.

Code
nodes <- data.frame(
  id = c("a", "b", "z", "one", "c", "L"),
  x  = c(0, 0, 1.6, 3.2, 3.2, 4.8),
  y  = c(1.85, 0.55, 1.2, 0.30, 1.2, 1.2),
  lab = c("a\n2", "b\n3", "z = a·b\n6", "1", "c = z + 1\n7", "L = c²\n49")
)
edges <- data.frame(
  from = c("a", "b", "z", "one", "c"),
  to   = c("z", "z", "c", "c", "L"),
  g    = c("42", "28", "14", "14", "1")
)
edges$x    <- nodes$x[match(edges$from, nodes$id)]
edges$y    <- nodes$y[match(edges$from, nodes$id)]
edges$xend <- nodes$x[match(edges$to, nodes$id)] - 0.36
edges$yend <- nodes$y[match(edges$to, nodes$id)] -
  0.22 * sign(nodes$y[match(edges$to, nodes$id)] - edges$y)
edges$mx <- (edges$x + edges$xend) / 2
edges$my <- (edges$y + edges$yend) / 2

nd <- rbind(transform(nodes, panel = "forward"),
            transform(nodes, panel = "backward"))
ed <- rbind(transform(edges, panel = "forward", lab = ""),
            transform(edges, panel = "backward", lab = edges$g))
nd$panel <- factor(nd$panel, levels = c("forward", "backward"))
ed$panel <- factor(ed$panel, levels = c("forward", "backward"))

ggplot() +
  geom_segment(data = ed, aes(x, y, xend = xend, yend = yend),
               colour = op_pal[["grey"]], linewidth = 0.5, arrow = arrow_op) +
  geom_label(data = nd, aes(x, y, label = lab, fill = id == "L"),
             size = 2.9, lineheight = 0.95,
             label.padding = unit(0.22, "lines"), label.size = 0.28,
             colour = op_pal[["ink"]]) +
  geom_label(data = ed[ed$panel == "backward", ], aes(mx, my, label = lab),
             size = 2.7, fill = "white", colour = op_pal[["coral"]],
             label.padding = unit(0.12, "lines"), label.size = 0) +
  scale_fill_manual(values = c("FALSE" = "white", "TRUE" = "#FDEDE9"), guide = "none") +
  facet_wrap(~panel) +
  coord_cartesian(xlim = c(-0.6, 5.4), ylim = c(-0.1, 2.35)) +
  plain
Figure 2: 一个乘法的计算图。左:前向,值沿箭头流动;右:反向,梯度沿原路返回。乘法的局部导数是另一个输入,所以 ∂L/∂a = 14×3 = 42。 | Computation graph for one multiplication. Left: forward, values flow along the arrows. Right: backward, gradients return the same way. The local derivative of a product is the other input, so ∂L/∂a = 14×3 = 42.

图里最容易被忽略的一点是:乘法的反向用的是另一个输入。\(z=ab\) 对 \(a\) 的导数等于 \(b\)。所以框架在设计算子时,不仅要写前向怎么算,还要写”为了算反向,前向的哪些值必须被保存下来”。这个”保存什么”的决定,直接决定了显存占用——第 5 章讲 FlashAttention 时会回到这里。

The easily missed point: the backward pass of a product uses the other input. Since \(\partial z/\partial a = b\), an operator’s design is not only “how to compute forward” but “which forward values must be kept so backward is possible”. That decision decides memory use — which is where FlashAttention comes back in section 5.

2. 一次计算如何变成一个神经元? / How one computation becomes a neuron

2.1 从 \(wx\) 到 \(\sigma(wx+b)\) / From \(wx\) to \(\sigma(wx+b)\)

线性模型只有一次乘法:\(y = wx\)。它的问题很直接——输入翻倍,输出就翻倍,无论 \(x\) 多大,输出都只能沿一条直线变化。加上偏置 \(b\) 只是把这条直线平移,仍然是一条直线。

A linear model is one multiplication: \(y = wx\). Its limitation is immediate — double the input and the output doubles; whatever \(x\) is, the output can only move along a straight line. Adding a bias \(b\) shifts that line, and it is still a line.

神经元做了两件事:先把多个输入加权求和,再用一个非线性函数 \(\sigma\) 把结果折一下:

A neuron does two things: it weighted-sums several inputs, then bends the result through a nonlinearity \(\sigma\):

\[z = \sum_{i=1}^{n} w_i x_i + b = \mathbf{w}^\top \mathbf{x} + b, \qquad y = \sigma(z)\]

这里 \(\mathbf{w}^\top\mathbf{x}\) 是内积,是一个基础算子;\(+b\) 是另一个基础算子;\(\sigma\) 是第三个。三者组合起来,才是一个”神经元”。换句话说,神经元不是算子,而是三个算子串联成的一个小模块。

Here \(\mathbf{w}^\top\mathbf{x}\) is a dot product, one primitive operator; \(+b\) is a second; \(\sigma\) is a third. Only the three together make a “neuron”. In other words, a neuron is not an operator but a small module built from three operators in series.

Code
w <- c(0.8, -1.2, 0.5)
x <- c(1.5, 0.5, 2.0)
b <- 0.3
terms <- w * x
z <- sum(terms) + b
y <- 1 / (1 + exp(-z))

steps <- data.frame(
  y = c(6, 4.8, 3.6, 2.4, 1.2),
  label = c("w · x\n1.60", "+ b\n+0.30", sprintf("z\n%.2f", z),
            "σ\nsigmoid", sprintf("y\n%.3f", y)),
  fill = c(op_pal[["pale"]], op_pal[["pale"]], "#E8F1F9", "#FDF0E4", "#E9F5F1")
)

p_left <- ggplot(steps) +
  geom_label(aes(1, y, label = label, fill = fill), size = 2.9, lineheight = 0.95,
             label.padding = unit(0.24, "lines"), label.size = 0.3,
             colour = op_pal[["ink"]]) +
  scale_fill_identity() +
  geom_segment(data = data.frame(y = steps$y[-5] - 0.42, yend = steps$y[-1] + 0.42),
               aes(x = 1, xend = 1, y = y, yend = yend),
               arrow = arrow_op, colour = op_pal[["grey"]], linewidth = 0.45) +
  coord_cartesian(xlim = c(0.55, 1.45), ylim = c(0.5, 6.7)) +
  plain

p_right <- ggplot(data.frame(z = seq(-6, 6, 0.05)), aes(z, plogis(z))) +
  geom_line(colour = op_pal[["blue"]], linewidth = 0.9) +
  geom_hline(yintercept = 0.5, linewidth = 0.25, colour = op_pal[["grey"]], linetype = "dotted") +
  annotate("segment", x = z, xend = z, y = 0, yend = y,
           colour = op_pal[["coral"]], linetype = "dashed", linewidth = 0.3) +
  annotate("point", x = z, y = y, colour = op_pal[["coral"]], size = 2.4) +
  annotate("text", x = z + 0.35, y = y - 0.06, hjust = 0,
           label = sprintf("σ(%.2f) = %.3f", z, y), size = 3,
           colour = op_pal[["coral"]]) +
  annotate("text", x = -5.8, y = 0.06, hjust = 0,
           label = "z = 1.90 落在陡峭段", size = 2.8, colour = op_pal[["grey"]]) +
  labs(x = "z", y = "σ(z)") +
  coord_cartesian(xlim = c(-6, 6), ylim = c(-0.02, 1.02))

p_left + p_right + plot_layout(widths = c(0.9, 1.6))
Figure 3: 一个神经元的运算分解。左:三次算子调用;右:sigmoid 把 z = 1.90 折成 0.870。数字由下方代码实际算出。 | Decomposing one neuron. Left: three operator calls in series. Right: sigmoid folds z = 1.90 into 0.870. Numbers come from the code below.
w·x = 0.8*1.5 + -1.2*0.5 + 0.5*2.0 = 1.60
z = 1.60 + 0.3 = 1.90
y = sigmoid(1.90) = 0.870

2.2 一层、一个模块、一个架构 / Layer, module, architecture

把 \(n\) 个神经元并排,让它们看同一份输入,就得到一层:\(y = \sigma(Wx + b)\),其中 \(W\) 是 \(n\times d\) 的矩阵。这一层在框架里是一个对象(nn.Linear + 激活),它的计算本质仍然只是”一次矩阵乘法 + 一次逐元素加法 + 一次逐元素非线性”。

Put \(n\) neurons side by side, all looking at the same input, and you get a layer: \(y=\sigma(Wx+b)\) with \(W\) an \(n\times d\) matrix. In a framework that layer is an object (nn.Linear plus an activation), but computationally it is still one matrix product, one elementwise add and one elementwise nonlinearity.

层再堆起来就是模块与架构。这三者的区别,不在于用了多少个算子,而在于信息以什么方式在元素之间流动:

Stacking layers gives modules and architectures. The difference between them is not how many operators are used, but how information moves between elements:

  • 层(layer):一次固定的映射,输入输出形状已知。
    Layer: one fixed mapping with a known input and output shape.
  • 模块(module):几个层按固定方式组合,对外仍是一个可调用的对象,例如一个残差块、一个注意力头。
    Module: several layers wired in a fixed pattern, still callable as one object — a residual block, an attention head.
  • 架构(architecture):模块的重复方式 + 数据在序列/图/网格上的流转规则,例如”重复 12 个相同模块,每个模块里先自注意力再三层感知机”。
    Architecture: how modules repeat and how data flows across a sequence, a graph or a grid — say, twelve identical blocks, each doing self-attention then an MLP.
Code
set.seed(11)
W1 <- matrix(c(0.9, -1.4, 0.6, 1.1, -0.7, 0.4), nrow = 3, byrow = TRUE)
b1 <- c(0.1, -0.2, 0.0)
W2 <- matrix(c(1.2, -0.9, 0.5, -0.6, 0.8, 1.1), nrow = 2, byrow = TRUE)
b2 <- c(0.05, -0.1)
x0 <- c(1.0, 0.5)

h_pre <- as.numeric(W1 %*% x0 + b1)
h <- pmax(h_pre, 0)
o_pre <- as.numeric(W2 %*% h + b2)
o <- 1 / (1 + exp(-o_pre))

layer_x <- c(1, 2, 3)
ys <- function(k) (k - 1) / 2 - (seq_len(k) - 1)

nodes <- rbind(
  data.frame(layer = 1, idx = 1:2, y = ys(2) * 0.45, value = x0),
  data.frame(layer = 2, idx = 1:3, y = ys(3) * 0.55, value = h),
  data.frame(layer = 3, idx = 1:2, y = ys(2) * 0.45, value = o)
)
nodes$x <- layer_x[nodes$layer]
nodes$lab <- sprintf("%.2f", nodes$value)

e1 <- expand.grid(inp = 1:2, hid = 1:3)
e1$w <- W1[cbind(e1$hid, e1$inp)]
e1$x <- 1; e1$y <- nodes$y[nodes$layer == 1][e1$inp]
e1$xend <- 2; e1$yend <- nodes$y[nodes$layer == 2][e1$hid]

e2 <- expand.grid(hid = 1:3, out = 1:2)
e2$w <- W2[cbind(e2$out, e2$hid)]
e2$x <- 2; e2$y <- nodes$y[nodes$layer == 2][e2$hid]
e2$xend <- 3; e2$yend <- nodes$y[nodes$layer == 3][e2$out]

w_edges <- rbind(e1[, c("x", "y", "xend", "yend", "w")],
                 e2[, c("x", "y", "xend", "yend", "w")])

ggplot() +
  geom_segment(data = w_edges,
               aes(x, y, xend = xend, yend = yend, linewidth = abs(w),
                   colour = w > 0), alpha = 0.75) +
  geom_point(data = nodes, aes(x, y, fill = value), size = 14, shape = 21,
             colour = op_pal[["ink"]], stroke = 0.4) +
  geom_text(data = nodes, aes(x, y, label = lab), size = 2.7,
            colour = op_pal[["ink"]]) +
  scale_linewidth(range = c(0.3, 2.6), guide = "none") +
  scale_colour_manual(values = c("TRUE" = op_pal[["blue"]], "FALSE" = op_pal[["orange"]]),
                      labels = c("negative", "positive"), name = NULL) +
  scale_fill_gradient(low = "white", high = op_pal[["teal"]], name = "activation") +
  scale_x_continuous(breaks = layer_x,
                     labels = c("input", "hidden\n(ReLU)", "output\n(sigmoid)")) +
  coord_cartesian(xlim = c(0.7, 3.3)) +
  theme(axis.text.y = element_blank(), axis.ticks.y = element_blank(),
        axis.title = element_blank(), axis.ticks.x = element_blank(),
        panel.border = element_blank())
Figure 4: 一个 2-3-2 网络的前向传播。节点颜色是激活值,边宽是权重绝对值,蓝为正值、橙为负值;数字是这一组权重的真实输出。 | Forward pass through a 2-3-2 network. Node colour is activation, edge width is |weight|, blue positive and orange negative; numbers are the actual outputs for this weight set.

这一个图里有四个层次同时出现:算子(乘法、加法、ReLU、sigmoid)、层(两层线性映射)、模块(整个 2-3-2 网络)、架构(前馈、逐层传递,没有回边也没有跨元素连接)。说”这是一个 2-3-2 的神经网络”时,描述的是架构;说”这是一个 ReLU 网络”时,描述的是选用的算子;说”这一层有 9 个参数”时,描述的是模块。

Four levels appear at once: operators (multiply, add, ReLU, sigmoid), layers (two linear maps), the module (the whole 2-3-2 net) and the architecture (feed-forward, layer by layer, no back edges, no cross-element links). “A 2-3-2 network” describes the architecture; “a ReLU network” describes the operator choice; “this layer has nine parameters” describes the module.

3. 现代 AI 常见的算子 / The operators modern AI is built from

3.1 矩阵乘法:一切的底座 / Matrix multiplication, the substrate

\[C_{ij}=\sum_{k=1}^{K} A_{ik}B_{kj}\]

这是同一行与同一列的内积:\(C\) 的第 \((i,j)\) 个元素,是 \(A\) 的第 \(i\) 行与 \(B\) 的第 \(j\) 列逐个相乘再相加。它的重要性不在数学上复杂(三次循环就能写完),而在于几乎所有别的算子都能被它包住:全连接层是它,卷积可以展开成它,注意力里两次核心运算也是它。

It is the inner product of one row with one column: entry \((i,j)\) of \(C\) is row \(i\) of \(A\) multiplied elementwise with column \(j\) of \(B\), then summed. Its importance is not mathematical sophistication — three nested loops describe it — but that almost everything else can be expressed through it: a dense layer is one, a convolution can be unrolled into one, and the two heavy steps inside attention are two.

Code
A <- matrix(c(1, 2, 0,
              -1, 1, 3), nrow = 2, byrow = TRUE)
B <- matrix(c(2, 0,
              1, -1,
              0, 2), nrow = 3, byrow = TRUE)
C <- A %*% B

grid_df <- function(M, xoff, ytop, name) {
  d <- expand.grid(i = seq_len(nrow(M)), j = seq_len(ncol(M)))
  d$v <- M[cbind(d$i, d$j)]
  d$x <- d$j + xoff
  d$y <- ytop - d$i
  d$mat <- name
  d
}
dA <- grid_df(A, 0, 2.6, "A")
dB <- grid_df(B, 4.2, 2.6, "B")
dC <- grid_df(C, 7.6, 2.6, "C")

high <- rbind(
  data.frame(x = dA$x[dA$i == 1], y = dA$y[dA$i == 1]),
  data.frame(x = dB$x[dB$j == 1], y = dB$y[dB$j == 1]),
  data.frame(x = dC$x[dC$i == 1 & dC$j == 1], y = dC$y[dC$i == 1 & dC$j == 1])
)

ggplot() +
  geom_tile(data = rbind(dA, dB, dC), aes(x, y, fill = v),
            width = 0.92, height = 0.92, colour = "white") +
  geom_tile(data = high, aes(x, y), width = 0.92, height = 0.92,
            fill = NA, colour = op_pal[["coral"]], linewidth = 0.8) +
  geom_text(data = rbind(dA, dB, dC), aes(x, y, label = v), size = 3.2) +
  annotate("text", x = c(1.5, 5, 8.5), y = 3.05, label = c("A (2 × 3)", "B (3 × 2)", "C = A·B"),
           size = 3.1, colour = op_pal[["grey"]]) +
  annotate("text", x = 5, y = -0.45,
           label = "C₁₁ = 1·2 + 2·1 + 0·0 = 4", size = 3.1, colour = op_pal[["coral"]]) +
  scale_fill_gradient(low = op_pal[["pale"]], high = op_pal[["blue"]], guide = "none") +
  coord_cartesian(xlim = c(0.3, 9.6), ylim = c(-0.85, 3.3)) +
  plain
Figure 5: 2 × 3 与 3 × 2 的矩阵乘法。高亮的是 C₁₁ 的计算:A 的第一行与 B 的第一列逐个相乘再相加,得到 4。 | A 2 × 3 times a 3 × 2 matrix. The highlighted entries show how C₁₁ is built: the first row of A times the first column of B, summed to 4.

计算量是 \(2mnk\):一个 \(d\times d\) 的方阵乘另一个,是 \(2d^3\)。这就是为什么大模型的成本几乎总能用一个式子概括——参数量 × token 数 × 层数,其中每一项背后都是一次矩阵乘法。

The cost is \(2mnk\): two \(d\times d\) matrices need \(2d^3\). That is why the cost of a large model can almost always be summarised as parameters × tokens × layers, and every term hides a matrix multiplication.

3.2 激活函数:给线性叠加折一道弯 / Activations, bending the linear stack

如果没有非线性,堆多少层都等于一层:\(W_2(W_1x)=(W_2W_1)x\)。激活函数是唯一打破这个等式的地方,所以它虽然只是一个逐元素函数,却决定了整个网络的表达能力。

Without nonlinearity, any stack collapses into one layer: \(W_2(W_1x)=(W_2W_1)x\). Activations are the only thing that breaks that identity, so a pointwise function ends up determining what the whole network can represent.

\[\mathrm{ReLU}(x)=\max(0,x),\qquad \mathrm{GELU}(x)=x\,\Phi(x),\qquad \sigma(x)=\frac{1}{1+e^{-x}}\]

Code
xs <- seq(-4, 4, length.out = 400)
relu  <- pmax(0, xs)
gelu  <- 0.5 * xs * (1 + tanh(sqrt(2 / pi) * (xs + 0.044715 * xs^3)))
sig   <- plogis(xs)
tanhx <- tanh(xs)
drelu  <- as.numeric(xs > 0)
dgelu  <- 0.5 * (1 + tanh(sqrt(2 / pi) * (xs + 0.044715 * xs^3))) +
  0.5 * xs * (1 - tanh(sqrt(2 / pi) * (xs + 0.044715 * xs^3))^2) *
  sqrt(2 / pi) * (1 + 3 * 0.044715 * xs^2)
dsig   <- sig * (1 - sig)
dtanh  <- 1 - tanhx^2

fns <- rbind(
  data.frame(x = xs, y = relu,  d = drelu,  name = "ReLU"),
  data.frame(x = xs, y = gelu,  d = dgelu,  name = "GELU"),
  data.frame(x = xs, y = sig,   d = dsig,   name = "sigmoid"),
  data.frame(x = xs, y = tanhx, d = dtanh,  name = "tanh")
)
fns$name <- factor(fns$name, levels = c("ReLU", "GELU", "sigmoid", "tanh"))

ggplot(fns) +
  geom_hline(yintercept = 0, linewidth = 0.2, colour = op_pal[["grey"]]) +
  geom_line(aes(x, y, colour = name), linewidth = 0.9) +
  geom_line(aes(x, d, colour = name), linewidth = 0.55, linetype = "dashed", alpha = 0.75) +
  facet_wrap(~name, nrow = 1) +
  scale_colour_manual(values = unname(op_pal[c("blue", "teal", "orange", "coral")]),
                      guide = "none") +
  labs(x = "x", y = NULL) +
  coord_cartesian(ylim = c(-1.15, 2.6))
Figure 6: 四种激活函数(实线)与它们的导数(虚线)。ReLU 在 0 处不可导、导数只有 0 和 1 两个值;GELU 平滑但计算更贵;sigmoid 与 tanh 两端饱和,梯度会趋近 0。 | Four activations (solid) and their derivatives (dashed). ReLU is non-differentiable at 0 and its derivative only takes 0 or 1; GELU is smooth but costlier; sigmoid and tanh saturate, so gradients vanish.

图里能读出三个实用结论。ReLU 的导数是 0 或 1,所以梯度要么原样通过、要么被砍断,这让它极快,也让”死神经元”成为可能。GELU 在 0 附近是平滑的,负半轴仍有很小的负值,梯度更连续,代价是要算 tanh 或 erf。sigmoid 与 tanh 在两端导数接近 0,层数一多梯度就传不回去——这是 2010 年代之前深层网络训不动的直接原因之一。

Three practical readings. ReLU’s derivative is 0 or 1, so gradients either pass through unchanged or die, which makes it fast and makes dead units possible. GELU is smooth near zero and keeps a small negative lobe, so the gradient is more continuous, at the price of a tanh or erf. Sigmoid and tanh flatten at both ends, so gradients shrink layer by layer — one direct reason deep networks were hard to train before the 2010s.

3.3 Softmax:把分数变成分配 / Softmax, turning scores into a budget

\[\mathrm{softmax}(z_i)=\frac{e^{z_i/T}}{\sum_j e^{z_j/T}}\]

它做的是归一化 + 放大差距:所有输出为正、和为 1;温度 \(T\) 越小,分布越尖(\(T\to 0\) 时接近 one-hot),\(T\) 越大越接近均匀分布。工程实现里会先减去最大值再取指数,避免溢出——这也是一种”算子级”的技巧:数学上等价,数值上完全不同。

It normalises and exaggerates: outputs are positive and sum to one; a smaller temperature \(T\) makes the distribution sharper (approaching one-hot as \(T\to0\)), a larger one flattens it. Real implementations subtract the maximum before exponentiating to avoid overflow — an operator-level trick that is mathematically identical and numerically different.

Code
logits <- c(2.0, 1.0, 0.1, -1.0)
tok4 <- c("TP53", "MDM2", "CDKN1A", "GAPDH")
temps <- c(0.5, 1, 2)
soft <- function(z, T) { e <- exp((z - max(z)) / T); e / sum(e) }

sm <- expand.grid(token = tok4, T = temps, stringsAsFactors = FALSE)
sm$p <- mapply(function(tk, tt) soft(logits, tt)[match(tk, tok4)], sm$token, sm$T)
sm$token <- factor(sm$token, levels = tok4)
sm$T <- factor(sm$T, labels = c("T = 0.5", "T = 1", "T = 2"))

ggplot(sm, aes(token, p, fill = token)) +
  geom_col(width = 0.68) +
  geom_text(aes(label = sprintf("%.2f", p)), vjust = -0.4, size = 2.9) +
  facet_wrap(~T, nrow = 1) +
  scale_fill_manual(values = unname(op_pal[c("blue", "teal", "orange", "coral")]),
                    guide = "none") +
  scale_y_continuous(limits = c(0, 1.05), breaks = c(0, 0.5, 1)) +
  labs(x = NULL, y = "probability") +
  theme(axis.text.x = element_text(angle = 25, hjust = 1))
Figure 7: 同一组 logits 在三个温度下的 softmax。T = 0.5 时最大的类别拿到 0.86,T = 2 时降到 0.45,分布明显变平。 | The same logits under three temperatures. At T = 0.5 the top class takes 0.86; at T = 2 it drops to 0.45 and the distribution flattens.

注意 y 轴上限是 1:每个面板里四根柱子加起来都等于 1,变化的只是”这份预算怎么分”。

Note the y axis tops out at 1: in every panel the four bars sum to 1, and only the split changes.

3.4 归一化:让数值待在可训练的区间 / Normalisation, keeping numbers in a trainable range

\[\mathrm{LayerNorm}(x)=\gamma\odot\frac{x-\mu}{\sqrt{\sigma^2+\epsilon}}+\beta\]

归一化的作用不是”让模型更好看”,而是控制每层输入的尺度,让学习率不必对每一层单独调。BatchNorm 按批统计,依赖同一批里的其他样本;LayerNorm 按单个样本的特征维统计,因此与批大小无关——这是 Transformer 用小批量也能稳定训练的关键。RMSNorm 省掉了减均值和加偏置,只除以均方根,计算更省。

Normalisation is not cosmetic; it keeps the scale of each layer’s input under control, so one learning rate works everywhere. BatchNorm uses batch statistics and therefore depends on the other samples in the batch; LayerNorm normalises within one sample’s feature vector, so it is batch-size independent — the reason Transformers train stably with small batches. RMSNorm drops the mean subtraction and the bias, dividing only by the root mean square, which is cheaper.

3.5 卷积:把”邻居”写进结构 / Convolution, baking locality into the structure

\[(X * K)_{ij}=\sum_{u}\sum_{v} X_{i+u-1,\,j+v-1}\,K_{uv}\]

卷积做的是局部加权求和,而且同一个小核在整张图上滑动。这带来两个免费的好处:参数量与图像大小无关(3×3 的核永远只有 9 个权重),以及平移等变(输入平移一格,输出也平移一格)。代价是感受野受限:单层只能看到 3×3,要靠堆层或池化才能看到更大的范围。

A convolution is a local weighted sum with the same small kernel sliding everywhere. Two free benefits follow: the parameter count is independent of image size (a 3×3 kernel always has nine weights), and the operator is translation-equivariant (shift the input by one pixel and the output shifts by one). The cost is a limited receptive field: one layer sees 3×3, and depth or pooling is needed to see further.

Code
X <- matrix(c(1, 2, 0, 1, 3,
              0, 1, 2, 2, 1,
              3, 1, 0, 1, 2,
              1, 0, 2, 3, 1,
              2, 2, 1, 0, 1), nrow = 5, byrow = TRUE)
K <- matrix(c(1, 0, -1,
              1, 0, -1,
              1, 0, -1), nrow = 3, byrow = TRUE)

conv_out <- matrix(0, 3, 3)
for (i in 1:3) for (j in 1:3) {
  conv_out[i, j] <- sum(X[i:(i + 2), j:(j + 2)] * K)
}

dX <- expand.grid(i = 1:5, j = 1:5)
dX$v <- X[cbind(dX$i, dX$j)]

p_in <- ggplot(dX) +
  geom_tile(aes(j, 6 - i, fill = v), width = 0.92, height = 0.92, colour = "white") +
  geom_text(aes(j, 6 - i, label = v), size = 3.1) +
  geom_rect(aes(xmin = 0.5, xmax = 3.5, ymin = 2.5, ymax = 5.5),
            fill = NA, colour = op_pal[["coral"]], linewidth = 0.8) +
  scale_fill_gradient(low = op_pal[["pale"]], high = op_pal[["blue"]], guide = "none") +
  annotate("text", x = 3, y = 0.15, size = 2.9, colour = op_pal[["coral"]],
           label = sprintf("窗口加权和 = %d", conv_out[1, 1])) +
  coord_equal(xlim = c(0.4, 5.6), ylim = c(-0.15, 6.0)) +
  labs(title = "input 5 × 5") + plain

dK <- expand.grid(i = 1:3, j = 1:3)
dK$v <- K[cbind(dK$i, dK$j)]
p_k <- ggplot(dK) +
  geom_tile(aes(j, 4 - i, fill = v), width = 0.92, height = 0.92, colour = "white") +
  geom_text(aes(j, 4 - i, label = v), size = 3.1) +
  scale_fill_gradient2(low = op_pal[["coral"]], mid = "white", high = op_pal[["teal"]],
                       guide = "none") +
  annotate("text", x = 2, y = 0.2, label = "kernel 3 × 3", size = 2.9,
           colour = op_pal[["grey"]]) +
  coord_equal(xlim = c(0.4, 3.6), ylim = c(-0.1, 4.0)) +
  labs(title = "kernel") + plain

dO <- expand.grid(i = 1:3, j = 1:3)
dO$v <- conv_out[cbind(dO$i, dO$j)]
p_out <- ggplot(dO) +
  geom_tile(aes(j, 4 - i, fill = v), width = 0.92, height = 0.92, colour = "white") +
  geom_text(aes(j, 4 - i, label = v), size = 3.1) +
  scale_fill_gradient2(low = op_pal[["coral"]], mid = "white", high = op_pal[["teal"]],
                       guide = "none") +
  annotate("text", x = 2, y = 0.2, label = "output 3 × 3", size = 2.9,
           colour = op_pal[["grey"]]) +
  coord_equal(xlim = c(0.4, 3.6), ylim = c(-0.1, 4.0)) +
  labs(title = "output") + plain

p_in | p_k | p_out
Figure 8: 5 × 5 输入与 3 × 3 核做一次卷积。左侧红框是第一个输出位置对应的窗口,右侧是完整的 3 × 3 输出,数字由代码逐个算出。 | A 3 × 3 kernel over a 5 × 5 input. The red box is the window for the first output; the right panel is the full 3 × 3 output, every entry computed by the code.

这张图里的核是一个竖直边缘检测器:中间一列是 0,左边加、右边减,所以输出在”左边比右边亮”的地方为正、反之为负。卷积核从来不是凭空学出来的东西——它就是在学”要在这个位置上做哪种加权比较”。

The kernel here is a vertical edge detector: a zero column in the middle, added on the left and subtracted on the right, so the output is positive where the left side is brighter than the right. Kernels are not magic; each is learning which weighted comparison to make at this position.

3.6 注意力:让任意两个位置直接对话 / Attention, letting any two positions talk

\[\mathrm{Attn}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V\]

把每个 token 变成三个向量:Query(我在找什么)、Key(我是什么)、Value(我能提供什么)。\(QK^\top\) 算出所有位置两两之间的分数,除以 \(\sqrt{d_k}\) 是防止点积随维度变大而爆炸,softmax 把分数变成权重,最后用权重对 Value 加权求和。注意力的本质是一次”内容寻址”的加权平均,与卷积最大的区别是:权重不是固定的核,而是由数据自己算出来的。

Each token becomes three vectors: Query (what I am looking for), Key (what I am), Value (what I can offer). \(QK^\top\) scores every pair of positions, dividing by \(\sqrt{d_k}\) keeps dot products from exploding with dimension, softmax turns scores into weights, and the output is a weighted sum of Values. Attention is content-addressed averaging: unlike convolution, the weights are not a fixed kernel but are computed from the data.

Code
tok <- c("TP53", "MDM2", "CDKN1A", "GAPDH")
Qm <- matrix(c(1.0, 0.2, -0.4,
               0.6, 0.9, 0.1,
               -0.3, 0.4, 1.1,
               0.1, -0.8, 0.2), nrow = 4, byrow = TRUE)
Km <- matrix(c(0.9, 0.1, -0.3,
               0.7, 0.8, 0.0,
               -0.2, 0.5, 1.0,
               0.0, -0.9, 0.1), nrow = 4, byrow = TRUE)
Vm <- matrix(c(1.0, 0.0,
               0.3, 1.2,
               -0.5, 0.6,
               0.2, -0.7), nrow = 4, byrow = TRUE)

scores <- Qm %*% t(Km) / sqrt(3)
wts <- t(apply(scores, 1, function(r) exp(r - max(r)) / sum(exp(r - max(r)))))

hs <- expand.grid(row = 1:4, col = 1:4)
hs$score <- scores[cbind(hs$row, hs$col)]
hs$w <- wts[cbind(hs$row, hs$col)]
hs$r <- factor(tok[hs$row], levels = rev(tok))
hs$c <- factor(tok[hs$col], levels = tok)

p_s <- ggplot(hs) +
  geom_tile(aes(c, r, fill = score), colour = "white", width = 0.95, height = 0.95) +
  geom_text(aes(c, r, label = sprintf("%.2f", score)), size = 2.9) +
  scale_fill_gradient2(low = op_pal[["blue"]], mid = "white", high = op_pal[["coral"]],
                       breaks = c(-0.5, 0, 0.5), name = "score") +
  labs(title = "Q·Kᵀ / √d", x = "key", y = "query") +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))

p_w <- ggplot(hs) +
  geom_tile(aes(c, r, fill = w), colour = "white", width = 0.95, height = 0.95) +
  geom_text(aes(c, r, label = sprintf("%.2f", w)), size = 2.9) +
  scale_fill_gradient(low = "white", high = op_pal[["teal"]], name = "weight") +
  labs(title = "softmax(·)", x = "key", y = NULL) +
  theme(axis.text.x = element_text(angle = 30, hjust = 1),
        axis.text.y = element_blank())

p_s | p_w
Figure 9: 四个 token 的自注意力。左:缩放前的点积分数(可正可负);右:按行 softmax 后的权重,每行加起来等于 1。 | Self-attention over four tokens. Left: raw scaled dot products (signed). Right: row-wise softmax weights; every row sums to 1.

右图每一行是一个 token 的”注意力预算”:TP53 把大部分权重给了自己,MDM2 则明显看向 CDKN1A。这种”谁看谁”的矩阵,正是后面讨论 Transformer 与卷积差别时最直接的证据。

Each row on the right is one token’s attention budget: TP53 keeps most weight on itself while MDM2 clearly looks toward CDKN1A. This who-looks-at-whom matrix is the most direct evidence when comparing Transformers with convolutions later.

Code
nd <- data.frame(
  id = c("X", "Wq", "Wk", "Wv", "QK", "scale", "soft", "AV", "out"),
  x = c(0.6, 1.9, 1.9, 1.9, 3.3, 4.3, 5.2, 6.4, 7.6),
  y = c(1.6, 2.6, 1.6, 0.6, 1.6, 1.6, 1.6, 1.6, 1.6),
  lab = c("X", "W_Q", "W_K", "W_V", "QKᵀ", "÷√d_k", "softmax", "×V", "output")
)
ed <- data.frame(
  from = c("X", "X", "X", "QK", "QK", "scale", "soft", "soft", "AV"),
  to   = c("Wq", "Wk", "Wv", "scale", "scale", "soft", "AV", "AV", "out")
)
ed <- ed[!duplicated(ed), ]
ed$x    <- nd$x[match(ed$from, nd$id)] + 0.34
ed$y    <- nd$y[match(ed$from, nd$id)]
ed$xend <- nd$x[match(ed$to, nd$id)] - 0.34
ed$yend <- nd$y[match(ed$to, nd$id)]

ggplot() +
  geom_segment(data = ed, aes(x, y, xend = xend, yend = yend),
               arrow = arrow_op, colour = op_pal[["grey"]], linewidth = 0.45) +
  geom_label(data = nd, aes(x, y, label = lab, fill = id %in% c("Wq", "Wk", "Wv")),
             size = 3.1, label.padding = unit(0.28, "lines"), label.size = 0.28,
             colour = op_pal[["ink"]]) +
  scale_fill_manual(values = c("FALSE" = "white", "TRUE" = "#E8F1F9"), guide = "none") +
  annotate("text", x = 1.25, y = 0.15, label = "3 次线性投影", size = 2.8,
           colour = op_pal[["blue"]]) +
  annotate("text", x = 4.7, y = 0.15, label = "3 次逐元素 + 1 次归约", size = 2.8,
           colour = op_pal[["orange"]]) +
  annotate("text", x = 7.1, y = 0.15, label = "1 次矩阵乘", size = 2.8,
           colour = op_pal[["teal"]]) +
  annotate("segment", x = 4.3, xend = 5.2, y = 2.55, yend = 2.55,
           colour = op_pal[["grey"]], linewidth = 0.3, linetype = "dashed") +
  coord_cartesian(xlim = c(0.1, 8.2), ylim = c(0.0, 3.0)) +
  plain
Figure 10: 注意力不是一个算子,而是至少六个算子组成的模块。每个框在框架里都对应一个 aten op;箭头是张量,不是数据流之外的魔法。 | Attention is not one operator but a module of at least six. Every box is an aten op in a framework; the arrows are tensors, nothing more.

这张拆解图是第 5、6 章的基础。说”FlashAttention 是一个新算子”和说”注意力是一个算子”,指的不是同一层东西:前者改的是这些框之间怎么交换数据,后者是这些框本身。

This decomposition underpins sections 5 and 6. “FlashAttention is a new operator” and “attention is an operator” are not statements about the same layer: the former changes how these boxes exchange data, the latter is about the boxes themselves.

3.7 图聚合与消息传递:在邻居之间传纸条 / Graph aggregation and message passing

图上的节点没有固定顺序,因此合适的算子必须对邻居排列不敏感。最基础的形式是:

Graph nodes have no canonical order, so the operator must be insensitive to how neighbours are permuted. The basic form is:

\[h_i'=\phi\!\left(h_i,\ \bigoplus_{j\in\mathcal{N}(i)} \psi(h_i,h_j)\right)\]

\(\psi\) 生成从 \(j\) 发给 \(i\) 的消息,\(\bigoplus\) 是一个置换不变的聚合(求和、求均值、取最大),\(\phi\) 更新中心节点。换成”求和”还是”求均值”,得到的是不同的归纳偏置:求和保留度数信息,求均值则归一化掉度数。

\(\psi\) builds the message from \(j\) to \(i\), \(\bigoplus\) is a permutation-invariant aggregation (sum, mean, max), and \(\phi\) updates the centre. Choosing sum versus mean is already a different inductive bias: sum keeps degree information, mean normalises it away.

Code
h <- c(centre = 1.0, n1 = 0.6, n2 = -0.4, n3 = 1.4, n4 = 0.2)
W <- 0.8
msg <- W * h[c("n1", "n2", "n3", "n4")]
agg <- mean(msg)
h_new <- 0.6 * h[["centre"]] + 0.4 * agg

pos <- data.frame(id = c("centre", "n1", "n2", "n3", "n4"),
                  x = c(0, 0, 1.6, 0, -1.6),
                  y = c(0, 1.6, 0, -1.6, 0))

edg <- data.frame(from = c("n1", "n2", "n3", "n4"),
                  to = "centre",
                  m = sprintf("%.2f", msg))
edg$x <- pos$x[match(edg$from, pos$id)]
edg$y <- pos$y[match(edg$from, pos$id)]
edg$xend <- pos$x[match(edg$to, pos$id)] - 0.30 * sign(edg$x)
edg$yend <- pos$y[match(edg$to, pos$id)] - 0.30 * sign(edg$y)
edg$mx <- (edg$x + edg$xend) / 2
edg$my <- (edg$y + edg$yend) / 2

pos$lab <- c(sprintf("h = %.1f", h[["centre"]]),
             sprintf("%.1f", h[["n1"]]), sprintf("%.1f", h[["n2"]]),
             sprintf("%.1f", h[["n3"]]), sprintf("%.1f", h[["n4"]]))

p_g <- ggplot() +
  geom_segment(data = edg, aes(x, y, xend = xend, yend = yend),
               arrow = arrow_op, colour = op_pal[["blue"]], linewidth = 0.5, alpha = 0.8) +
  geom_point(data = pos, aes(x, y, fill = id == "centre"), size = 13, shape = 21,
             colour = op_pal[["ink"]], stroke = 0.4) +
  geom_text(data = pos, aes(x, y, label = lab), size = 2.7) +
  geom_label(data = edg, aes(mx, my, label = m), size = 2.7, colour = op_pal[["coral"]],
             fill = "white", label.padding = unit(0.12, "lines"), label.size = 0) +
  scale_fill_manual(values = c("FALSE" = "white", "TRUE" = op_pal[["pale"]]), guide = "none") +
  coord_cartesian(xlim = c(-2.3, 2.3), ylim = c(-2.1, 2.1)) +
  labs(title = "aggregate over N(i)") + plain

agg_df <- data.frame(
  step = factor(c("messages", "mean", "update"), levels = c("messages", "mean", "update")),
  value = c(mean(msg), agg, h_new)
)
p_b <- ggplot(agg_df, aes(step, value, fill = step)) +
  geom_col(width = 0.55) +
  geom_text(aes(label = sprintf("%.2f", value)), vjust = -0.4, size = 3) +
  scale_fill_manual(values = unname(op_pal[c("blue", "teal", "orange")]), guide = "none") +
  scale_y_continuous(limits = c(-0.1, max(agg_df$value) * 1.35)) +
  labs(x = NULL, y = "value") +
  theme(axis.text.x = element_text(size = 9))

p_g + p_b + plot_layout(widths = c(1.15, 1))
Figure 11: 一个节点的邻域聚合。四个邻居各发一条消息,聚合(这里是均值)后与中心节点自身状态一起更新。数字是这一步的真实计算结果。 | Neighbourhood aggregation for one node. Four neighbours send messages; after aggregation (mean here) the centre is updated together with its own state. Numbers are the actual result of this step.

3.8 基础算子与复合模块 / Primitives versus composite modules

把上面几种放在一起,会看到一个明显的分层:有些东西在框架里就是一个 op(加法、乘法、exp、max),有些则是一串 op 的组合,只不过被包成了一个有名字的模块。

Putting them together reveals a clear split: some things are a single op in a framework (add, multiply, exp, max), while others are chains of ops packaged under one name.

名称 / Name 数学形式 / Form 参数 / Params 复杂度 / Cost 改变了什么 / What it changes
矩阵乘 / matmul \(C=AB\) 无 / none \(O(mnk)\) 线性混合特征
ReLU \(\max(0,x)\) 无 / none \(O(n)\) 保留正部、制造稀疏
Softmax \(e^{z}/\sum e^{z}\) 无 / none \(O(n)\) 变成概率分配
LayerNorm 见 3.4 \(2d\) \(O(nd)\) 稳定尺度
卷积 / conv \(X*K\) \(k^2c_{in}c_{out}\) \(O(hwk^2c^2)\) 局部、平移等变
注意力 / attention 见 3.6 \(4d^2\) \(O(n^2d)\) 任意两位置交互
图聚合 / aggregation 见 3.7 取决于 \(\psi\) \(O(|E|d)\) 邻域信息汇入

English

Name Form Params Cost What it changes
matmul \(C=AB\) none \(O(mnk)\) linearly mixes features
ReLU \(\max(0,x)\) none \(O(n)\) keeps positives, creates sparsity
Softmax \(e^{z}/\sum e^{z}\) none \(O(n)\) turns scores into a budget
LayerNorm see 3.4 \(2d\) \(O(nd)\) stabilises scale
conv \(X*K\) \(k^2c_{in}c_{out}\) \(O(hwk^2c^2)\) locality, translation equivariance
attention see 3.6 \(4d^2\) \(O(n^2d)\) interaction between any two positions
aggregation see 3.7 depends on \(\psi\) \(O(|E|d)\) pulls neighbourhood information in

表里”参数”一列值得注意:matmul、ReLU、softmax 都没有可训练参数,它们是固定的信息处理规则;卷积、注意力和图聚合有参数,它们学会了怎么处理。所谓”设计一个新算子”,绝大多数时候是在设计后一类里”怎么参数化”。

The “Params” column matters: matmul, ReLU and softmax have no trainable parameters; they are fixed rules of information processing. Convolution, attention and aggregation do have parameters; they learn how to process. Most “new operator” work is about how to parameterise this second group.

4. 为什么不同的神经网络使用不同的算子? / Why different networks need different operators

前面几节把算子当成零件来讲。但真实的网络不是零件的随机堆叠:CNN、Transformer、GNN 之所以长得不一样,是因为它们对”数据是什么形状”做了不同的假设。这些假设有个正式名字——归纳偏置(inductive bias)。换个说法:算子决定了”谁可以和谁交换信息、按什么规则交换”,而架构就是这套规则的组合方式。

The previous sections treated operators as parts. But real networks are not random piles of parts: CNNs, Transformers and GNNs look different because they make different assumptions about what the data is shaped like. The formal name for such an assumption is an inductive bias. Put differently: an operator decides who may exchange information with whom, and by which rule; an architecture is a way of composing those rules.

Code
n_pos <- 6
pr <- as.matrix(expand.grid(i = 1:n_pos, j = 1:n_pos))
pairs_cnn <- pr[pr[, "i"] < pr[, "j"] & pr[, "j"] - pr[, "i"] <= 1, , drop = FALSE]
pairs_tf  <- pr[pr[, "i"] < pr[, "j"], , drop = FALSE]
pairs_gnn <- rbind(c(1, 2), c(2, 3), c(3, 4), c(4, 5), c(5, 6), c(1, 4), c(3, 6))

mk_edge <- function(p, panel) {
  data.frame(i = p[, 1], j = p[, 2], x = p[, 1], xend = p[, 2],
             y = 0, yend = 0, panel = panel)
}
ed <- rbind(
  mk_edge(pairs_cnn, sprintf("CNN: 局部窗口 / local  (E = %d)", nrow(pairs_cnn))),
  mk_edge(pairs_tf,  sprintf("Transformer: 全连接 / all pairs  (E = %d)", nrow(pairs_tf))),
  mk_edge(pairs_gnn, sprintf("GNN: 数据给边 / given graph  (E = %d)", nrow(pairs_gnn)))
)
ed$panel <- factor(ed$panel, levels = unique(ed$panel))
nd <- data.frame(x = rep(1:n_pos, 3), y = 0,
                 panel = factor(rep(levels(ed$panel), each = n_pos),
                                levels = levels(ed$panel)))

ggplot() +
  geom_curve(data = ed, aes(x, y, xend = xend, yend = yend),
             curvature = -1.05, colour = op_pal[["blue"]],
             linewidth = 0.30, alpha = 0.7) +
  geom_point(data = nd, aes(x, y), size = 3.6, colour = op_pal[["ink"]]) +
  facet_wrap(~ panel) +
  coord_cartesian(xlim = c(0.6, 6.4), ylim = c(-0.05, 1.10)) +
  plain
Figure 12: 同一串 6 个位置,三种「谁能和谁说话」的约定。CNN 只让相邻位置相连(3 个位置的窗口),Transformer 让所有位置两两相连,GNN 的边由数据给定。括号里是这一层的边数。 | The same six positions under three different rules about who may talk to whom. A CNN connects only neighbours (window of 3), a Transformer connects every pair, and a GNN takes its edges from the data. The brackets give the edge count of that layer.

图里三个网络的节点数完全一样,参数预算也可以调到一样,但信息流动的图截然不同。这张图是本章的骨架:接下来每一种架构,都只是在回答”这张图从哪来”。

The three panels have the same nodes and could be given the same parameter budget, yet the graph of information flow differs completely. That is the backbone of this chapter: every architecture below is just a different answer to “where does this graph come from”.

4.1 CNN:结构直接告诉你谁是邻居 / The structure tells you who the neighbours are

图像有一个天然属性:相邻像素相关,远处的像素关系更弱。卷积把这个先验直接写进算子——每一层只连接 \(k\times k\) 邻域,并且同一组权重在整幅图上滑动:

An image has a built-in property: nearby pixels are correlated, distant ones less so. Convolution writes that prior straight into the operator — each layer connects only a \(k\times k\) neighbourhood, and the same weights slide across the whole image:

\[h_i^{(\ell+1)}=\sum_{j\in\mathcal{N}_k(i)} w_{j-i}\, h_j^{(\ell)}\]

\(w\) 的下标是相对位移 \(j-i\),不是绝对位置。所以整幅图共用 \(k^2\) 个权重,而不是每个位置各有一套。这就是参数共享,它换来的性质叫平移等变:图像整体平移,输出整体平移,内容不变。

The subscript of \(w\) is the relative offset \(j-i\), not the absolute position. So the whole image shares \(k^2\) weights instead of learning one set per location. That is weight sharing, and the property it buys is translation equivariance: shift the image and the output shifts with it, content unchanged.

代价也很明确:一次卷积只能看到邻居。\(L\) 层 \(k\times k\) 卷积之后,单个输出能看到的范围(感受野)是

The cost is equally clear: one convolution only sees neighbours. After \(L\) layers of \(k\times k\) convolution, the range a single output can see — the receptive field — is

\[r_L = 1 + L\,(k-1)\]

\(k=3\) 时,\(L=1,3,10\) 分别给出 \(3,7,21\)。想让两个相隔 100 像素的东西直接交互,要么堆 50 层,要么换一种算子。“深”在 CNN 里不是审美选择,是为了扩大感受野付出的代价。

With \(k=3\), \(L=1,3,10\) gives \(3,7,21\). To let two things 100 pixels apart interact directly you either stack 50 layers or change the operator. Depth in a CNN is not an aesthetic choice; it is the price of growing the receptive field.

4.2 Transformer:不预设邻居,让内容自己决定 / No preset neighbours, content decides

Transformer 的做法相反:它不假设谁和谁相邻,而是让每个位置和所有位置算一个相关性分数,再用这些分数加权求和(3.6 节已经算过一遍)。这意味着邻接关系不是超参数,而是每次前向传播时根据输入内容重新计算的。这个性质常被叫做内容寻址(content-based addressing)。

A Transformer does the opposite: it does not assume who is next to whom. Every position scores its relation to every other position and takes a weighted sum (section 3.6 already computed one). So the adjacency is not a hyperparameter; it is recomputed from the content on every forward pass. This is usually called content-based addressing.

由此得到两个后果。第一,任意两个位置的距离都是 1 跳,长程依赖不用堆层数。第二,算子对输入顺序本身不敏感——把句子打乱,注意力输出只是跟着打乱。这叫置换等变:\(\mathrm{Attn}(P H) = P\,\mathrm{Attn}(H)\),其中 \(P\) 是任意置换矩阵。顺序信息必须由位置编码额外注入,这是”自由”的代价。

Two consequences follow. First, any two positions are one hop apart, so long-range dependencies need no depth. Second, the operator itself is blind to input order — permute the sentence and the output permutes with it. That is permutation equivariance: \(\mathrm{Attn}(P H) = P\,\mathrm{Attn}(H)\) for any permutation matrix \(P\). Order has to be injected separately by positional encoding; that is the price of the freedom.

第三个后果最实际:成本。\(n\) 个位置两两配对,分数矩阵是 \(n\times n\),计算量 \(O(n^2 d)\),显存 \(O(n^2)\)。序列从 1k 涨到 32k,\(n^2\) 这一项涨 1000 倍。第 5 章要讲的 FlashAttention,针对的正是这个 \(n^2\) 带来的显存搬运。

The third consequence is the practical one: cost. Pairing \(n\) positions gives an \(n\times n\) score matrix, \(O(n^2 d)\) compute and \(O(n^2)\) memory. Going from 1k to 32k tokens multiplies the \(n^2\) term by 1000. FlashAttention, in section 5, targets exactly the memory traffic that this \(n^2\) creates.

4.3 GNN:邻居由数据给出 / The data supplies the neighbours

图数据既没有网格,也没有全连接:一个分子里原子只和成键的原子相连,一个细胞里基因只和调控它的基因相连。邻接矩阵 \(A\) 是输入的一部分,不是模型的超参数。于是算子必须写成”沿边汇总”:

Graph data is neither a grid nor fully connected: in a molecule an atom bonds only with a few others; in a cell a gene is regulated by a few others. The adjacency \(A\) is part of the input, not a hyperparameter of the model. The operator therefore has to be written as “sum along the edges”:

\[h_i'=\phi\Big(h_i,\ \bigoplus_{j\in\mathcal{N}(i)}\psi(h_i,h_j)\Big)\]

因为图的节点没有天然编号,聚合 \(\bigoplus\) 必须置换不变:求和、求均值、取最大都满足,按顺序拼接就不满足。3.7 节的例子用的是均值。

Because graph nodes have no canonical numbering, the aggregation \(\bigoplus\) must be permutation invariant: sum, mean and max all qualify; concatenation in a fixed order does not. The example in section 3.7 used the mean.

GNN 的主要麻烦是过平滑(over-smoothing):每做一次聚合,相邻节点的表示就被拉近一点,层数一多,全图节点的向量趋于相同,可分性反而下降。经验上 2–4 层往往最好,这与 CNN”越深越好”的直觉相反。原因还是那条:算子的几何性质不同,深度的收益与代价就不同。

The main trouble with GNNs is over-smoothing: each aggregation pulls neighbouring representations closer, so with many layers all node vectors converge and separability drops. Empirically 2–4 layers is often best, which contradicts the CNN intuition that deeper is better. The reason is the same one: change the geometry of the operator and you change what depth buys you.

4.4 RGAT:边不仅有方向,还有类型 / Edges have types, not just directions

现实中的图往往带关系标签:基因 A 激活基因 B、药物 C 抑制蛋白 D、蛋白 E 与蛋白 F 形成复合物。“激活”和”抑制”是两种完全不同的信息,如果把它们压进同一个权重矩阵,模型就只能学到两者的平均值。

Real graphs usually carry relation labels: gene A activates gene B, drug C inhibits protein D, protein E forms a complex with protein F. “Activates” and “inhibits” are different kinds of information; collapsing both into one weight matrix forces the model to learn their average.

关系图注意力网络(RGAT)的做法很直接:每种关系一套参数、一套注意力。

Relational graph attention networks (RGAT) take the direct route: one set of parameters and one attention distribution per relation.

\[h_i'=\phi\Big(h_i,\ \bigoplus_{r\in\mathcal{R}}\ \sum_{j\in\mathcal{N}_r(i)} \alpha_{ij}^{(r)}\, W_r\, h_j\Big),\qquad \alpha_{ij}^{(r)}=\mathrm{softmax}_{j\in\mathcal{N}_r(i)}\big(a_r^{\top}[W_r h_i \,\|\, W_r h_j]\big)\]

注意两处细节。第一,softmax 是在每个关系内部做的,而不是在所有邻居上一起做——否则激活邻居多的时候会把抑制邻居的权重挤没。第二,\(W_r\) 让”同样的输入值”在不同关系下产生不同的消息。

Two details matter. First, the softmax runs within each relation, not across all neighbours; otherwise a node with many activating neighbours would crowd out its inhibiting ones. Second, \(W_r\) lets the same input value produce different messages under different relations.

Code
h_r <- c(centre = 1.0, n1 = 0.6, n2 = -0.4, n3 = 1.4, n4 = 0.2)
rel <- c(n1 = "act", n2 = "inh", n3 = "act", n4 = "inh")
W_r <- c(act = 0.8, inh = -1.2)
nb  <- c("n1", "n2", "n3", "n4")

smax <- function(v) { e <- exp(v - max(v)); e / sum(e) }

proj   <- as.numeric(W_r[rel] * h_r[nb]); names(proj) <- nb
a_act  <- smax(proj[c("n1", "n3")])
a_inh  <- smax(proj[c("n2", "n4")])
alpha  <- c(a_act, a_inh)[nb]
msg    <- alpha * proj[nb]
agg_r  <- sum(msg)
h_new_r <- 0.5 * h_r[["centre"]] + 0.5 * agg_r

a_blind <- smax(h_r[nb])
agg_b   <- sum(a_blind * h_r[nb])
h_new_b <- 0.5 * h_r[["centre"]] + 0.5 * agg_b

pos <- data.frame(id = c("centre", nb),
                  x = c(0, 0, 1.75, 0, -1.75),
                  y = c(0, 1.75, 0, -1.75, 0))
pos$kind <- c("centre", rel[nb])
pos$lab <- c(sprintf("centre\n%.2f", h_r[["centre"]]),
             sprintf("%s\n%.2f", nb, h_r[nb]))

edg <- data.frame(from = nb, to = "centre", kind = rel[nb])
edg$x    <- pos$x[match(edg$from, pos$id)]
edg$y    <- pos$y[match(edg$from, pos$id)]
edg$xend <- pos$x[match(edg$to, pos$id)] - 0.42 * sign(edg$x)
edg$yend <- pos$y[match(edg$to, pos$id)] - 0.42 * sign(edg$y)
is_v <- abs(pos$y[match(edg$from, pos$id)]) > 0.5   # neighbour sits above or below
edg$mx <- edg$x * 0.52 + ifelse(is_v, 0.30, 0)
edg$my <- edg$y * 0.52 + ifelse(is_v, 0, 0.30)
edg$elab <- sprintf("a=%.2f\nm=%.2f", alpha[edg$from], msg[edg$from])

p_net <- ggplot() +
  geom_segment(data = edg[edg$kind == "act", ], aes(x, y, xend = xend, yend = yend),
               arrow = arrow_op, colour = op_pal[["blue"]], linewidth = 0.7) +
  geom_segment(data = edg[edg$kind == "inh", ], aes(x, y, xend = xend, yend = yend),
               arrow = arrow_op, colour = op_pal[["coral"]], linewidth = 0.7,
               linetype = "22") +
  geom_label(data = edg, aes(mx, my, label = elab), size = 2.1, lineheight = 0.9,
             colour = op_pal[["grey"]], fill = "white", label.size = 0,
             label.padding = unit(0.08, "lines")) +
  geom_point(data = pos, aes(x, y, fill = kind), size = 14, shape = 21,
             colour = op_pal[["ink"]], stroke = 0.4) +
  geom_text(data = pos, aes(x, y, label = lab), size = 2.5, lineheight = 0.95) +
  scale_fill_manual(values = c(centre = op_pal[["pale"]], act = "white", inh = "#FBE7E3"),
                    labels = c(centre = "centre", act = "activates", inh = "inhibits"),
                    name = NULL) +
  coord_cartesian(xlim = c(-2.8, 2.8), ylim = c(-2.7, 2.3)) +
  guides(fill = guide_legend(override.aes = list(size = 4))) +
  labs(title = "RGAT: softmax within each relation") + plain +
  theme(legend.position = "bottom", legend.text = element_text(size = 8))

cmp <- data.frame(
  model = factor(c("relation-aware", "relation-blind"),
                 levels = c("relation-aware", "relation-blind")),
  value = c(h_new_r, h_new_b),
  fill  = c(op_pal[["teal"]], op_pal[["grey"]])
)
p_cmp <- ggplot(cmp, aes(model, value)) +
  geom_col(aes(fill = fill), width = 0.55) +
  geom_text(aes(label = sprintf("%.2f", value)), vjust = -0.5, size = 3.4) +
  scale_fill_identity() +
  scale_y_continuous(limits = c(0, max(cmp$value) * 1.28)) +
  labs(x = NULL, y = expression(h[i]^"'")) +
  theme(axis.text.x = element_text(size = 8.5))

p_net + p_cmp + plot_layout(widths = c(1.55, 1))
Figure 13: 关系感知的聚合。左侧:中心节点有 2 个激活邻居、2 个抑制邻居,每种关系各自做 softmax(括号里是注意力权重与它发出的消息)。右侧:同一个邻域,如果忽略关系类型、只做一次 softmax,得到的聚合值明显不同。 | Relation-aware aggregation. Left: two activating and two inhibiting neighbours, each relation running its own softmax (the brackets give the attention weight and the message it sends). Right: the same neighbourhood with relations ignored and a single softmax — a visibly different aggregate.

同一批数字,两种读法给出 \(\hat h_i'=1.07\)(关系感知)与 \(0.93\)(忽略关系)。差别不算大,但方向是对的:抑制邻居在第二种写法里被当成了普通邻居,它们的负号只体现在数值上,没有体现在结构上。

The same numbers read two ways give \(\hat h_i'=1.07\) (relation-aware) and \(0.93\) (relation-blind). The gap is modest but the direction is right: in the second version inhibiting neighbours are treated as ordinary ones, so their negative sign lives in the value but not in the structure.

RGAT 属于”第一类更好”(第 5 章):它换的是信息处理机制,不是实现方式。判据是表达能力与归纳偏置是否匹配任务,而它的代价同样是参数:关系多的时候 \(|\mathcal{R}|\) 套权重会迅速膨胀,实践中常用基分解(basis decomposition)把 \(W_r\) 写成少量基矩阵的线性组合。

RGAT belongs to the “first kind of better” (section 5): it changes the information-processing mechanism, not the implementation. The criterion is whether its expressiveness and inductive bias match the task, and the cost is again parameters: with many relations, \(|\mathcal{R}|\) weight matrices blow up, so practice often writes \(W_r\) as a linear combination of a few basis matrices.

4.5 JEPA:它不是算子,是另一层的东西 / JEPA is a different level, not an operator

到这里需要一个澄清。I-JEPA、V-JEPA 这类工作经常和注意力、卷积并列出现在讨论里,但它们回答的问题不一样。

A clarification is needed here. I-JEPA and V-JEPA are often mentioned alongside attention and convolution, but they answer a different question.

Attention 回答”信息怎么在位置之间流动”,JEPA 回答”模型应该预测什么、在哪里算误差”。 I-JEPA 的做法是:遮住图像的一部分块,用一个上下文编码器处理可见块,去预测被遮住块在表示空间里的向量;目标向量由另一个指数滑动平均(EMA)的编码器产生,只对预测端回传梯度。它不重建像素,因此在表示空间里算损失。类比一下:反向传播不是算子,而是训练算法;JEPA 同理,是一种自监督训练目标加一套非对称的编码器安排。它内部用的算子仍然是 ViT 的注意力和 MLP。

Attention answers “how does information flow between positions”; JEPA answers “what should the model predict, and where is the loss taken”. I-JEPA masks part of the image, encodes the visible part, and predicts the representation of the masked part; the target comes from an exponential-moving-average encoder, and gradients flow only through the prediction side. It never reconstructs pixels, so the loss lives in representation space. The analogy: backpropagation is not an operator but a training algorithm; JEPA is a self-supervised objective plus an asymmetric encoder arrangement. The operators inside it are still the ViT’s attention and MLPs.

所以说”JEPA 是一个算子”和说”Transformer 是一个算子”一样,都滑到了错误的层级。它可以被描述为”一种用预测式自监督训练的表征学习框架”,但要讨论它的计算,还是得回到 attention、LayerNorm、matmul 这些真正的算子。第 8 章的层级图会把这件事一次说清。

So “JEPA is an operator” slides to the wrong level, exactly like “a Transformer is an operator”. It is better described as a representation-learning framework trained with predictive self-supervision; to discuss its computation you still return to attention, LayerNorm and matmul. The hierarchy in section 8 settles this once and for all.

4.6 四张图,四种信息几何 / Four graphs, four geometries

Four graphs, four geometries. 把上面的讨论压成一张表:

架构 / Model 邻接从哪来 / Where adjacency comes from 权重是否共享 / Sharing 核心算子 / Core operator 主要代价 / Main cost
CNN 由网格与核大小决定 / grid + kernel size 全图共享 / shared everywhere 局部加权和 / local weighted sum 感受野靠深度增长 / receptive field grows with depth
Transformer 由内容实时计算 / computed from content 每对位置不同 / per pair \(QK^\top\) + softmax + \(V\) \(O(n^2)\) 计算与显存 / \(O(n^2)\) compute and memory
GNN 输入图的边 / edges of the input graph 每层共享 / per layer 置换不变聚合 / permutation-invariant aggregation 过平滑 / over-smoothing
RGAT 输入图的边 + 关系标签 / edges + relation labels 每关系一套 / per relation 关系内 softmax + \(W_r\) 参数量随关系数增长 / params grow with \(|\mathcal{R}|\)

English

Model Where adjacency comes from Sharing Core operator Main cost
CNN grid + kernel size shared everywhere local weighted sum receptive field grows with depth
Transformer computed from content per pair \(QK^\top\) + softmax + \(V\) \(O(n^2)\) compute and memory
GNN edges of the input graph per layer permutation-invariant aggregation over-smoothing
RGAT edges + relation labels per relation softmax within relation + \(W_r\) params grow with \(|\mathcal{R}|\)

这张表最重要的一列是”邻接从哪来”。它决定了算子能表达什么、需要多少数据、以及会在哪里失效。换架构的本质,往往是换一种邻接假设;而换算子的本质,是换在这张邻接图上”怎么汇总”。两者相关,但不是同一件事。

The most important column is “where adjacency comes from”. It determines what the operator can express, how much data it needs, and where it fails. Changing the architecture is mostly changing the adjacency assumption; changing the operator is changing how you aggregate on that adjacency. The two are related, but they are not the same thing.

4.7 每种架构现在到底在做什么 / What each architecture actually does today

抽象地谈”归纳偏置”容易空。下面这张表把四种架构落到具体的任务、具体的系统和具体的输入输出上。选它的理由不是”这些系统最有名”,而是它们能说明:同一类任务被不同架构做过,差别就出在数据形状上。

Talking about inductive bias in the abstract goes empty quickly. The table below grounds each architecture in concrete tasks, concrete systems and concrete inputs and outputs. They are chosen not because they are the most famous, but because together they show that the same task family has been solved by different architectures, and the difference comes down to the shape of the data.

架构 具体任务 代表系统 为什么是这种算子
CNN 医学影像分割:CT/MRI 中的器官、病灶、细胞核 nnU-Net 组织纹理是局部模式,同一套卷积核可以滑过整幅图;平移等变省参数
CNN DNA 序列上的调控信号:转录因子结合位点、ChIP-seq 覆盖度 BPNet motif 只有十几 bp,出现位置不固定,正是”局部 + 共享权重”擅长的事
Transformer 蛋白序列语言模型,输出用于变体效应预测、结构建模 ESM-2;AlphaFold2 的 Evoformer 残基之间的共变是长程的,任意两位需要直接交互,而不是叠 50 层卷积
Transformer 单细胞表达谱:细胞类型注释、扰动响应预测 scGPT、Geneformer 基因没有天然顺序和固定邻接,“看哪些基因”必须由内容决定
Transformer 语音识别、图像分类 Whisper、ViT 同一套”全对全 + 内容寻址”可以跨模态复用,代价是数据和算力
GNN 分子性质与生物活性预测 GIN、GINE(OGB 的 molhiv、molpcba 基准) 原子和化学键本来就是图;预测结果必须与原子编号无关
GNN 蛋白–配体结合构象;空间转录组的区域划分 EquiBind、DiffDock;GraphST 3D 邻接或空间邻接直接就是边,不需要”学”出来
RGAT 知识图谱补全、药物重定位、逆合成 R-GCN、RGAT(FB15k-237、WN18RR);Hetionet 边的类型带语义:“治疗”“导致”“结合”不是同一件事,压成一种边就丢了信息
JEPA 自监督预训练目标(图像、视频) I-JEPA、V-JEPA 不是算子:它规定”在表示空间预测什么、在哪里算损失”,内部仍是 attention 与 MLP

English

Model Concrete task Representative system Why this operator
CNN Medical image segmentation: organs, lesions, nuclei in CT/MRI nnU-Net Tissue texture is a local pattern, so one kernel can slide over the whole image; translation equivariance saves parameters
CNN Regulatory signal from DNA: transcription-factor binding, ChIP-seq coverage BPNet A motif spans ~10 bp and can appear anywhere — exactly the “local + shared weights” case
Transformer Protein language models, used downstream for variant effects and structure ESM-2; the Evoformer in AlphaFold2 Co-variation between residues is long-range; any two positions need direct interaction instead of 50 stacked convolutions
Transformer Single-cell expression: cell-type annotation, perturbation response scGPT, Geneformer Genes have no natural order or fixed adjacency; “which genes to read” must be decided from content
Transformer Speech recognition, image classification Whisper, ViT One “all-pairs + content addressing” recipe transfers across modalities, at the cost of data and compute
GNN Molecular property and bioactivity prediction GIN, GINE (OGB molhiv, molpcba) Atoms and bonds already form a graph; predictions must not depend on atom numbering
GNN Protein–ligand binding pose; spatial domain calling in spatial transcriptomics EquiBind, DiffDock; GraphST 3D adjacency or spatial adjacency is the edge set — nothing to learn
RGAT Knowledge-graph completion, drug repurposing, retrosynthesis R-GCN, RGAT (FB15k-237, WN18RR); Hetionet Edge types carry meaning: “treats”, “causes” and “binds” are not the same edge
JEPA A self-supervised training objective (images, video) I-JEPA, V-JEPA Not an operator: it fixes what is predicted and where the loss lives; inside, it is still attention and MLPs

表里最值得注意的一点是:很多任务是”谁都能做”的。图像分类既被 ResNet 做过,也被 ViT 做过;分子性质预测既可以用 GNN,也可以把 SMILES 当句子喂给 Transformer。所以”某架构能做某任务”从来不是论据,真正的论据是在什么数据量下、用多少参数、泛化到哪里。ViT 原论文给出的就是这个结论:小数据上不如 CNN,大规模预训练之后反超——变的是数据,不是算子突然变强了。

The thing worth noticing is that many of these tasks are done by everyone. Image classification was done by ResNet and by ViT; molecular property prediction can use a GNN or feed SMILES to a Transformer as if it were a sentence. So “architecture X can do task Y” is never the argument; the argument is at what data scale, with how many parameters, and how far does it generalise. The original ViT paper makes exactly this point: it loses to a CNN on small data and overtakes it after large-scale pretraining — what changed was the data, not a sudden jump in the operator.

生物学里的例子能把这件事说得更具体。同样是”预测基因表达”,Enformer 吃的是约 200 kb 的 DNA 序列,用卷积提取局部 motif、再用注意力连接远端增强子,于是它天然适合问”这个变异会不会改变表达”;scGPT 与 Geneformer 吃的是几千维的表达向量,关注的不是碱基序列而是细胞状态,于是注意力被用来连接基因而不是连接碱基;GEARS 吃的是基因本体图加扰动标签,用它来外推到没做过的组合扰动。三个模型都在”预测表达”,但输入的形状决定了邻接的定义,邻接又决定了算子的形态。

Biological examples make this concrete. For “predict gene expression”, Enformer takes roughly 200 kb of DNA, uses convolution for local motifs and attention to link distal enhancers, which makes it a natural fit for asking whether a variant changes expression. scGPT and Geneformer take a few thousand expression values and care about cell state rather than bases, so attention links genes instead of nucleotides. GEARS takes a gene-ontology graph plus perturbation labels and extrapolates to combinations never measured. All three “predict expression”, but the shape of the input fixes what adjacency means, and adjacency fixes the form of the operator.

最后一层提醒:任务的名字一样,难度可以差很远。同样是”预测扰动后的表达”,同分布留出测试集与跨细胞系、跨物种的分布外测试,是两个难度;同样是”知识图谱补全”,随机划分和按时间划分,是两个难度。读一篇架构论文时,看到任务名之后应该立刻问:划分方式是什么,baseline 是谁,有没有信息泄漏。这三个问题比”用的什么架构”更能决定结论是否可信。

A final caveat: the name of a task says little about its difficulty. “Predict expression after perturbation” is one problem on an in-distribution holdout and a much harder one across cell lines or species; “knowledge-graph completion” is one problem under a random split and another under a temporal split. On reading an architecture paper, the questions to ask right after the task name are: how was the split made, who is the baseline, and is there leakage. Those three decide whether the conclusion holds far more than the choice of architecture does.

5. 为什么大家都说自己的算子更好? / Why everyone claims their operator is better

“我们提出了一个新的算子,效果更好。”这句话在论文摘要里出现频率极高,但它可能指三件完全不同的事。分不清这三件事,就没法判断一项工作到底先进在哪里,也没法复现它的结论。

“We propose a new operator that performs better.” That sentence appears in an enormous number of abstracts, yet it can mean three completely different things. Without separating them you cannot tell what a piece of work actually improves, nor reproduce its claims.

5.1 一句话,三种含义 / One sentence, three meanings

把差别摊开:

Laid out plainly:

  • 第一类:换了信息处理机制。同样的输入输出接口,数学上做了不同的事:换聚合函数、换打分方式、引入关系类型、加乘法交互。改变的是模型能表达什么。
    The mechanism. Same interface, different mathematics: a different aggregation, a different scoring rule, relation types, multiplicative interactions. What changes is what the model can express.
  • 第二类:换了底层实现。数学完全一样,只是数据在芯片上搬得更少、算子启动次数更少、显存占用更低。改变的是同样的结果要花多少时间和内存。
    The implementation. Identical mathematics; fewer bytes moved, fewer kernel launches, less memory. What changes is how much time and memory the same answer costs.
  • 第三类:换了整体设计。算子没变,变的是它们怎么拼:残差、归一化的位置、路由、训练目标、推理策略。改变的是整个系统的行为。
    The design. The operators are unchanged; their wiring is: residuals, normalisation placement, routing, objectives, inference strategy. What changes is the behaviour of the whole system.

混淆的典型症状是把第三类说成第一类(“我们发明了新算子”,其实是把 LayerNorm 挪到了注意力之前),或者把第二类说成第一类(“新算子更快”,其实数学一模一样,只是不物化中间矩阵)。

The typical symptom is calling a third-kind change a first-kind one (“we invented a new operator” — actually LayerNorm was moved before attention), or a second-kind change a first-kind one (“the new operator is faster” — the mathematics is identical, only the intermediate matrix is no longer materialised).

5.2 第一类:换信息处理机制 / First kind: change the mechanism

这类工作改的是 3.7 节那个公式里的某个部件:

This kind of work changes a component of the formula in section 3.7:

\[h_i'=\phi\Big(h_i,\ \bigoplus_{j\in\mathcal{N}(i)}\psi(h_i,h_j)\Big)\]

  • 换 \(\bigoplus\):均值 → 求和 → 注意力加权。GIN 的结论是求和比均值和最大值更有表达力,因为只有求和能区分”两个邻居都是 1”和”一个邻居是 2”。
    Change \(\bigoplus\): mean → sum → attention-weighted. The GIN result is that sum is more expressive than mean or max, because only sum distinguishes “two neighbours equal to 1” from “one neighbour equal to 2”.
  • 换 \(\psi\):用 \(W[h_i\|h_j]\)、用 \(h_i\odot h_j\)、用带关系标签的 \(W_r\)。
    Change \(\psi\): use \(W[h_i\|h_j]\), use \(h_i\odot h_j\), use a relation-specific \(W_r\).
  • 换打分方式:点积、加性、多层感知机。注意力家族的大部分变体都在这一格。
    Change the scoring rule: dot product, additive, an MLP. Most of the attention family lives in this cell.

判据也随之明确:在相同参数量、相同数据、相同训练预算下,表达能力是否真的变强,并且这种变强是否与任务匹配? 表达力更强不等于泛化更好——一个能拟合任何函数的算子,也可能只是更快地记住了训练集。这一点在第 6 章还会回来。

The criterion follows: with the same parameter count, data and training budget, is the model genuinely more expressive, and does that extra expressiveness match the task? More expressiveness is not better generalisation — an operator that can fit anything may simply memorise the training set faster. Section 6 returns to this.

5.3 第二类:换底层实现 / Second kind: change the implementation

这一类最容易讲清楚,也最容易被误解成数学创新。先把四个量分开:

This kind is the clearest, and the most often mistaken for mathematical novelty. Start by separating four quantities:

  • FLOPs(计算量):这个算子需要多少次浮点运算。矩阵乘 \(m\times n\) 与 \(n\times k\) 是 \(2mnk\)。
    FLOPs (work): how many floating-point operations the operator needs. An \(m\times n\) by \(n\times k\) matmul is \(2mnk\).
  • 显存(capacity):中间结果要占多少字节,决定 batch 能不能放下。
    Memory (capacity): how many bytes the intermediates occupy; it decides whether the batch fits.
  • 带宽(bandwidth):每秒能从显存搬到计算单元多少字节,单位 GB/s。
    Bandwidth: bytes per second that can move from memory to the compute units, in GB/s.
  • 吞吐(throughput):单位时间真正完成的工作量,取决于上面三个以及并行度。
    Throughput: work actually finished per unit time, depending on the above plus parallelism.

把它们连起来的是算术强度(arithmetic intensity):每搬运一个字节,能做多少次浮点运算。

The quantity that links them is arithmetic intensity: how many floating-point operations you get per byte moved.

\[I=\frac{\text{FLOPs}}{\text{bytes moved}}\]

\(I\) 越大,计算越”划算”;\(I\) 越小,程序越像在搬数据而不是在算。两行真实数字可以说明差别有多大:一个 \(1024^3\) 的 fp16 矩阵乘,FLOPs 是 \(2\times1024^3=2.15\times10^{9}\),搬运 \(3\times1024^2\times2=6.29\) MB,\(I\approx341\);而一个逐元素加法,每个元素 1 次浮点运算、读写各 2 字节,\(I=0.25\)。相差 1360 倍。

Larger \(I\) is better value; smaller \(I\) means the program is moving data rather than computing. Two real numbers show the spread: a \(1024^3\) fp16 matmul does \(2\times1024^3=2.15\times10^{9}\) FLOPs while moving \(3\times1024^2\times2=6.29\) MB, so \(I\approx341\); an elementwise add does one FLOP per element and moves 2 bytes in plus 2 out, so \(I=0.25\). That is a factor of 1360.

Code
peak <- 300    # TFLOPS, order of magnitude for an fp16 tensor-core accelerator
bw   <- 2000   # GB/s, i.e. 2 TB/s
ai   <- 10^seq(-1.2, 4.2, length.out = 300)
roof <- data.frame(ai = ai, tflops = pmin(peak, ai * bw / 1000))

pts <- data.frame(
  label = c("elementwise add\n(I = 0.25)", "matmul 1024^3\n(I = 341)",
            "matmul 8192^3\n(I = 2731)"),
  ai = c(0.25, 1024 / 3, 8192 / 3)
)
pts$tflops <- pmin(peak, pts$ai * bw / 1000)

ggplot(roof, aes(ai, tflops)) +
  geom_line(colour = op_pal[["blue"]], linewidth = 0.9) +
  geom_point(data = pts, aes(ai, tflops), colour = op_pal[["coral"]], size = 2.6) +
  geom_text(data = pts, aes(ai, tflops, label = label),
            size = 2.6, colour = op_pal[["ink"]], vjust = c(1.6, -0.6, -0.6),
            hjust = c(-0.05, 1.1, 1.1), lineheight = 0.9) +
  geom_vline(xintercept = 150, linetype = "22", colour = op_pal[["grey"]], linewidth = 0.4) +
  annotate("text", x = 165, y = 8, label = "ridge  I = 150", size = 2.6,
           colour = op_pal[["grey"]], hjust = 0) +
  scale_x_log10(labels = scales::label_number(accuracy = 1),
                expand = expansion(mult = c(0.04, 0.06))) +
  scale_y_log10(labels = scales::label_number(accuracy = 1),
                expand = expansion(mult = c(0.06, 0.16))) +
  labs(x = "arithmetic intensity  I  (FLOP / byte)",
       y = "achievable performance  (TFLOPS)") +
  theme(axis.text = element_text(size = 8))
Figure 14: Roofline 模型。斜线段受带宽限制(\(\text{性能}=\text{带宽}\times I\)),水平段受峰值算力限制。设峰值 300 TFLOPS、带宽 2000 GB/s,转折点在 \(I=150\)。逐元素加法落在斜线上(受带宽限制),两个矩阵乘落在水平段(受算力限制)。 | The roofline model. The sloped part is bandwidth-limited (\(\text{perf}=\text{bandwidth}\times I\)); the flat part is compute-limited. With a 300 TFLOPS peak and 2000 GB/s of bandwidth the ridge sits at \(I=150\). An elementwise add lands on the slope (bandwidth-bound); both matmuls land on the plateau (compute-bound).

这张图解释了为什么”优化算子”多数时候是”减少搬运”:如果算子落在斜线上,算力再翻倍也没用,带宽才是天花板。

This figure explains why “optimising an operator” usually means “moving fewer bytes”: if the operator sits on the slope, doubling peak compute changes nothing — bandwidth is the ceiling.

算子融合(operator fusion)是最基本的手段。一段 LayerNorm → GELU 的顺序计算,朴素实现会启动两个 kernel,每个都把整块张量写回显存再读出来;融合之后只有一次读和一次写。

Operator fusion is the most basic lever. A LayerNorm → GELU sequence naively launches two kernels, each writing a full tensor back to memory and reading it again; once fused there is one read and one write.

Code
N <- 1024; d <- 512; bpp <- 2
act <- N * d * bpp / 1e6      # MB
wgt <- d * d * bpp / 1e6      # MB

fz <- data.frame(
  path = factor(rep(c("4 kernels", "1 fused kernel"), c(4, 1)),
                levels = c("1 fused kernel", "4 kernels")),
  grp  = factor(c("matmul", "bias add", "layernorm", "gelu", "fused"),
                levels = c("matmul", "bias add", "layernorm", "gelu", "fused")),
  mb   = c(act + wgt + act, 2 * act, 2 * act, 2 * act, act + wgt + act)
)
tot <- fz %>% group_by(path) %>% summarise(mb = sum(mb), .groups = "drop")

ggplot(fz, aes(mb, path, fill = grp)) +
  geom_col(width = 0.42) +
  geom_text(aes(label = ifelse(mb > 1, sprintf("%.2f", mb), "")),
            position = position_stack(vjust = 0.5), size = 2.6, colour = "white") +
  geom_text(data = tot, aes(mb, path, label = sprintf("total %.2f MB", mb)),
            inherit.aes = FALSE, hjust = -0.12, size = 3.1, colour = op_pal[["ink"]]) +
  scale_fill_manual(values = c("matmul" = op_pal[["blue"]], "bias add" = op_pal[["teal"]],
                               "layernorm" = op_pal[["orange"]], "gelu" = op_pal[["coral"]],
                               "fused" = op_pal[["blue"]]),
                    name = NULL,
                    breaks = c("matmul", "bias add", "layernorm", "gelu")) +
  scale_x_continuous(limits = c(0, 11.2), expand = expansion(mult = c(0, 0))) +
  labs(x = "MB moved to/from HBM", y = NULL,
       title = sprintf("fusion cuts traffic by %.1fx", tot$mb[2] / tot$mb[1])) +
  theme(axis.text.y = element_text(size = 9), legend.position = "bottom",
        legend.text = element_text(size = 7.4), legend.key.size = unit(0.34, "cm"),
        legend.box.margin = margin(t = 2))
Figure 15: 算子融合前后的显存往返。设 fp16、\(N=1024\)、\(d=512\):激活张量 1.05 MB,权重 0.52 MB。四个独立 kernel 一共搬 8.91 MB,融合成一个 kernel 后只需 2.62 MB,约为原来的 1/3.4。这是按张量形状算出的下界,不是实测时间。 | Memory traffic before and after fusion. With fp16, \(N=1024\), \(d=512\): a 1.05 MB activation and a 0.52 MB weight. Four separate kernels move 8.91 MB; a single fused kernel needs 2.62 MB, about 1/3.4. This is a lower bound computed from tensor shapes, not a measured runtime.

FlashAttention 是同一逻辑做到极致的例子。3.6 节拆过注意力的四个框,朴素实现会把 \(n\times n\) 的分数矩阵整个写进显存:\(n=1024\)、\(d=64\)、fp16 时,这个矩阵是 2.10 MB,而 \(Q,K,V\) 各只有 0.13 MB。分数矩阵要被写一次、读一次,softmax 之后再写一次、读一次——\(n^2\) 这一项的往返完全盖过了真正的计算。

FlashAttention is the same logic taken to the limit. Section 3.6 decomposed attention into four boxes; the naive implementation writes the full \(n\times n\) score matrix to memory. At \(n=1024\), \(d=64\), fp16, that matrix is 2.10 MB while \(Q,K,V\) are 0.13 MB each. The score matrix is written once and read once, then the same again after softmax — the \(n^2\) round trips completely dominate the actual arithmetic.

Code
N <- 1024; d <- 64; bpp <- 2
sz_nd <- N * d * bpp / 1e6       # MB per N x d tensor
sz_nn <- N * N * bpp / 1e6       # MB per N x N matrix

flash <- data.frame(
  item = c("Q,K,V read", "S write", "S read", "P write", "P read", "O write"),
  mb   = c(3 * sz_nd, sz_nn, sz_nn, sz_nn, sz_nn, sz_nd),
  path = "naive"
)
f2 <- data.frame(item = c("Q,K,V read", "O write"),
                 mb = c(3 * sz_nd, sz_nd), path = "tiled")
fl <- rbind(flash, f2)
fl$item <- factor(fl$item, levels = c("Q,K,V read", "S write", "S read",
                                      "P write", "P read", "O write"))
fl$path <- factor(fl$path, levels = c("naive", "tiled"))

tot2 <- data.frame(path = factor(c("naive", "tiled"), levels = c("naive", "tiled")),
                   mb = c(sum(flash$mb), sum(f2$mb)))

ggplot(fl, aes(mb, path, fill = item)) +
  geom_col(width = 0.5) +
  geom_text(aes(label = ifelse(mb > 0.4, sprintf("%.2f", mb), "")),
            position = position_stack(vjust = 0.5), size = 2.5, colour = "white") +
  geom_text(data = tot2, aes(mb, path, label = sprintf("%.2f MB", mb)),
            inherit.aes = FALSE, hjust = -0.15, size = 3) +
  scale_fill_manual(values = c("Q,K,V read" = op_pal[["blue"]], "S write" = op_pal[["teal"]],
                               "S read" = "#6FB3A6", "P write" = op_pal[["orange"]],
                               "P read" = "#F7C46C", "O write" = op_pal[["coral"]]),
                    name = NULL) +
  scale_x_continuous(expand = expansion(mult = c(0, 0.13))) +
  labs(x = "MB moved to/from HBM", y = NULL,
       title = sprintf("naive %.2f MB vs tiled %.2f MB  (%.1fx)",
                       sum(flash$mb), sum(f2$mb), sum(flash$mb) / sum(f2$mb))) +
  theme(axis.text.y = element_text(size = 9), legend.text = element_text(size = 7.4),
        legend.key.size = unit(0.34, "cm"), legend.position = "bottom")
Figure 16: 注意力在 \(N=1024\)、\(d=64\)、fp16 下的 HBM 往返(MB)。朴素实现把 \(N\times N\) 的分数矩阵与概率矩阵物化,四次往返共 8.91 MB;分块实现让 \(N\times N\) 块留在片上 SRAM,只搬 \(Q,K,V\) 与输出,共 0.52 MB,相差约 17 倍。 | HBM traffic for attention at \(N=1024\), \(d=64\), fp16, in MB. The naive path materialises the \(N\times N\) score and probability matrices: four round trips, 8.91 MB. The tiled path keeps \(N\times N\) tiles in on-chip SRAM and only moves \(Q,K,V\) plus the output: 0.52 MB, roughly 17x less.

关键在于:FlashAttention 没有改变注意力的数学。它把 \(Q,K,V\) 切块放进片上内存,用在线 softmax 分批累积,反向传播时重算而不是存下中间矩阵。输出与标准实现数值等价(差别只在浮点误差量级),所以它属于第二类,不属于第一类。原作者报告的单算子加速在 2–4 倍量级,具体数字随序列长度、头维度、硬件变化;真正值得记住的是那句判断:它省的是字节,不是 FLOPs——FLOPs 甚至因为重算而略有增加。

The crucial point: FlashAttention does not change the mathematics of attention. It tiles \(Q,K,V\) into on-chip memory, accumulates with an online softmax, and recomputes intermediates in the backward pass instead of storing them. The output is numerically equivalent to the standard path (differences at the level of floating-point error), so it belongs to the second kind, not the first. The authors report 2–4x single-operator speedups, varying with sequence length, head dimension and hardware; what is worth remembering is the judgement: it saves bytes, not FLOPs — FLOPs even rise slightly because of recomputation.

第二类的判据因此很干脆:输出是否等价?如果是,就只能用时间、显存、能耗来比较,不能用”效果更好”来宣称。

The criterion for the second kind is therefore blunt: is the output equivalent? If so, it may only be compared on time, memory and energy — never advertised as “better results”.

5.4 第三类:换整体设计 / Third kind: change the design

第三类连算子都不换,换的是拼装方式:

The third kind does not even change the operators; it changes how they are wired:

  • 残差连接:\(y=x+f(x)\)。\(f\) 里的算子一个没变,但梯度多了一条恒等通路,深层网络才训得动。
    Residual connections: \(y=x+f(x)\). Not one operator inside \(f\) changes, yet gradients get an identity path and deep networks become trainable.
  • 归一化的位置:pre-LN 与 post-LN 用的是同一个 LayerNorm,训练稳定性差别很大。
    Placement of normalisation: pre-LN and post-LN use the same LayerNorm, with very different training stability.
  • 混合专家(MoE):把一个大前馈层换成若干专家加一个路由。路由是新的,专家内部的算子还是原来的。
    Mixture of experts: one large feed-forward layer becomes several experts plus a router. The router is new; the operators inside each expert are the same.
  • KV 缓存:推理时把已经算过的键值存下来,不改变任何数学,只改变重复计算。
    The KV cache: store keys and values already computed, changing no mathematics, only recomputation.

这些改动的价值可能远大于任何一个算子,但它们不该被描述成”新算子”。反过来也成立:一个数学上很漂亮的新算子,如果放进现有架构里和别的东西冲突(比如破坏归一化的尺度假设),端到端可能一无所得。

The value of such changes can far exceed that of any single operator, but they should not be described as “new operators”. The converse also holds: a mathematically elegant new operator that conflicts with its surroundings (say, it breaks a normalisation’s scale assumption) may deliver nothing end to end.

5.5 三类对照 / The three kinds side by side

第一类 机制 / mechanism 第二类 实现 / implementation 第三类 设计 / design
改变了什么 / changes 数学映射本身 / the mapping itself 数据怎么搬 / how data moves 算子怎么拼 / how ops are wired
输出是否等价 / output equivalent 不等价 / no 等价 / yes 不等价 / no
该用什么指标 / metric 表达能力、同预算下的精度、泛化 / expressiveness, accuracy at equal budget, generalisation 延迟、吞吐、显存峰值、能耗 / latency, throughput, peak memory, energy 端到端指标、训练稳定性、数据效率 / end-to-end metrics, stability, data efficiency
典型例子 / examples GIN 的求和聚合、GAT、RGAT / sum aggregation in GIN, GAT, RGAT 算子融合、FlashAttention、稀疏化 / fusion, FlashAttention, sparsity 残差、pre-LN、MoE、KV 缓存 / residuals, pre-LN, MoE, KV cache
常见误读 / common misreading “更强所以更好” / “more expressive, therefore better” “更快所以是数学创新” / “faster, therefore novel mathematics” “整体更好所以是新算子” / “better overall, therefore a new operator”

English

mechanism implementation design
changes the mapping itself how data moves how ops are wired
output equivalent no yes no
metric expressiveness, accuracy at equal budget, generalisation latency, throughput, peak memory, energy end-to-end metrics, stability, data efficiency
examples sum aggregation in GIN, GAT, RGAT fusion, FlashAttention, sparsity residuals, pre-LN, MoE, KV cache
common misreading “more expressive, therefore better” “faster, therefore novel mathematics” “better overall, therefore a new operator”

有了这张表,第 8 章末尾那串”该问的问题”就有了依据。现在先处理一个更根本的问题:如果真想造一个新算子,从哪里下手?

With this table the question list at the end of section 8 has a basis. First, a more fundamental question: if you really want to build a new operator, where do you start?

6. 能不能自己发明一种算子? / Can you invent an operator yourself?

可以,而且门槛比想象的低——难的不是写出公式,而是证明它值得存在。这一节从一个最朴素的线性式出发,一步步加东西,看每一步多出了什么、代价是什么。

Yes, and the barrier is lower than it looks. What is hard is not writing the formula but showing it deserves to exist. Start from the plainest linear expression and add one thing at a time, watching what each step buys and what it costs.

6.1 一步步把一个线性式改造掉 / Mutating a linear expression, one step at a time

第 0 步:\(y=wx\)。一个标量权重,能表达的只有过原点的直线。

Step 0: \(y=wx\). One scalar weight; the only functions it can express are lines through the origin.

第 1 步:\(y=wx+b\)。加一个偏置,直线可以平移。参数量 1 → 2,能力没有质变。

Step 1: \(y=wx+b\). Add a bias and the line can shift. Parameters go 1 → 2; nothing qualitative changes.

第 2 步:\(y=\sigma(wx+b)\)。外面套一个非线性。这一步是质变的开始:单层感知机仍然不能表示 XOR,但一旦把若干个这样的单元并排、再叠一层,就能表示任意连续函数(万能逼近)。注意”能表示”不等于”少量参数就能表示”——这是后面所有争论的根源。

Step 2: \(y=\sigma(wx+b)\). Wrap it in a nonlinearity. This is where things change qualitatively: a single perceptron still cannot represent XOR, but a few of these units side by side, stacked one more layer, can represent any continuous function (universal approximation). Note that “can represent” is not “can represent with few parameters” — that gap is the root of every later argument.

第 3 步:\(y=w_1x_1+w_2x_2+w_{12}x_1x_2\)。加一个交互项。前两步里两个输入只能各自贡献、然后相加;有了 \(x_1x_2\),一个输入的效果取决于另一个输入的取值。用统计的话说,这从”主效应模型”变成了”带交互的模型”。

Step 3: \(y=w_1x_1+w_2x_2+w_{12}x_1x_2\). Add an interaction term. In the first two steps the two inputs contribute separately and are then summed; with \(x_1x_2\) the effect of one input depends on the value of the other. In statistical language this moves from a main-effects model to one with interactions.

第 4 步:\(y_i=\sum_j a_{ij}(x)\,v_j\),其中 \(a_{ij}\) 是输入的函数,而且 \(\sum_j a_{ij}=1\)。这就是注意力的骨架。把它和第 0 步对比会发现:数学形式几乎一样,唯一的区别是权重不再是参数,而是输入的函数。注意力之所以强,就强在这一处;它之所以贵,也贵在这一处——每来一个输入都要重新算一遍权重。

Step 4: \(y_i=\sum_j a_{ij}(x)\,v_j\), where \(a_{ij}\) is a function of the input and \(\sum_j a_{ij}=1\). That is the skeleton of attention. Compare it with step 0 and the mathematical form is almost identical: the only difference is that the weights are no longer parameters but functions of the input. That single change is why attention is powerful, and also why it is expensive — the weights must be recomputed for every new input.

第 5 步:\(y_i=\sum_{r}\sum_{j\in\mathcal{N}_r(i)} a_{ij}^{(r)}(x)\,W_r\,v_j\)。加上邻接结构和关系类型,就是第 4 章的 RGAT。到这里,“算子”这个词已经覆盖了从 2 个参数到上亿参数的整条谱系。

Step 5: \(y_i=\sum_{r}\sum_{j\in\mathcal{N}_r(i)} a_{ij}^{(r)}(x)\,W_r\,v_j\). Add adjacency and relation types and you have the RGAT of section 4. By now “operator” spans the whole range from 2 parameters to hundreds of millions.

这条路径值得记住的地方是:每一步都只改了一个地方。真正被引用的新算子,几乎都能被这样拆成”在某一步换了某个部件”,而不是凭空出现。

What is worth remembering is that each step changed exactly one thing. Almost every new operator that gets cited can be decomposed this way — some component of some step was swapped — rather than appearing out of nowhere.

6.2 六个可以拧的旋钮 / Six knobs you can turn

把 3.7 节那个消息传递公式拆开,可调的地方其实不多:

Decompose the message-passing formula from section 3.7 and there are only a few places to intervene:

Code
card <- data.frame(
  x = rep(c(1, 2, 3), times = 2),
  y = rep(c(2, 1), each = 3),
  head = c("1  聚合 aggregation", "2  打分 scoring", "3  参数共享 sharing",
           "4  关系 relation", "5  稀疏 sparsity", "6  归一化 normalisation"),
  body = c("mean / sum / max / softmax\n影响:度数信息保不保留",
           "dot / additive / MLP\n影响:相似性怎么定义",
           "全局 / 每层 / 每关系\n影响:参数量与迁移性",
           "无 / 有类型 / 带符号\n影响:异质边怎么处理",
           "全连接 / kNN / 局部窗口\n影响:成本与感受野",
           "无 / softmax / L2\n影响:消息尺度的稳定性")
)

ggplot(card) +
  geom_rect(aes(xmin = x - 0.46, xmax = x + 0.46, ymin = y - 0.40, ymax = y + 0.40),
            fill = op_pal[["pale"]], colour = op_pal[["blue"]], linewidth = 0.35) +
  geom_text(aes(x, y + 0.26, label = head), size = 3.2, fontface = "bold",
            colour = op_pal[["blue"]]) +
  geom_text(aes(x, y - 0.02, label = body), size = 2.6, lineheight = 1.05,
            colour = op_pal[["ink"]]) +
  coord_cartesian(xlim = c(0.45, 3.55), ylim = c(0.5, 2.5)) +
  plain
Figure 17: 设计一个新算子时可以拧的六个旋钮。每个旋钮下面给出可选做法与它主要影响的东西。真正的新算子通常只动其中一两个,其余保持不变——这也是做消融实验时该控制的变量。 | Six knobs available when designing an operator, each with its options and what it mainly affects. A genuine new operator usually turns only one or two of them and leaves the rest fixed — which is exactly what an ablation must control for.

一个诚实的提醒:这六个旋钮并不是独立的。改聚合函数会改变梯度尺度,改稀疏性会改变有效感受野,改归一化会改变最优学习率。所以”只换了一个算子”的实验,往往需要重新调超参数才算公平比较——很多”新算子更好”的结论,差的就是这一步。

An honest caveat: the six knobs are not independent. Changing the aggregation changes gradient scale, changing sparsity changes the effective receptive field, changing normalisation changes the optimal learning rate. So an experiment that “swaps one operator” usually needs its hyperparameters retuned before the comparison is fair — and that missing step explains many “the new operator is better” claims.

6.3 一个能跑的小实验:聚合函数不是中立的 / A runnable experiment: aggregation is not neutral

取 400 个假想节点,每个节点有 5 个邻居,邻居的值来自标准正态分布。定义三个任务:预测邻居的和、均值、最大值。然后分别用三种聚合去拟合,看均方误差。

Take 400 synthetic nodes with 5 neighbours each, neighbour values drawn from a standard normal. Define three tasks — predict the sum, the mean and the max of the neighbours — then fit each with each of the three aggregations and compare mean squared error.

Code
set.seed(20261029)
G <- 400; K <- 5
H <- matrix(rnorm(G * K), G, K)

truth <- list(sum = rowSums(H), mean = rowMeans(H), max = apply(H, 1, max))
fitted <- list(sum = rowSums(H), mean = rowMeans(H), max = apply(H, 1, max))

grid <- expand.grid(agg = names(fitted), task = names(truth), stringsAsFactors = FALSE)
grid$mse <- mapply(function(a, t) mean((fitted[[a]] - truth[[t]])^2), grid$agg, grid$task)
grid$agg  <- factor(grid$agg,  levels = c("sum", "mean", "max"))
grid$task <- factor(grid$task, levels = c("sum", "mean", "max"))

ggplot(grid, aes(agg, mse, fill = task)) +
  geom_col(position = position_dodge(width = 0.78), width = 0.7) +
  geom_text(aes(label = sprintf("%.2f", mse)),
            position = position_dodge(width = 0.78), vjust = -0.45, size = 2.5) +
  scale_fill_manual(values = unname(op_pal[c("blue", "teal", "orange")]),
                    name = "task") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.14))) +
  labs(x = "aggregation", y = "MSE vs. the true target") +
  theme(legend.position = "right")
Figure 18: 聚合函数与任务假设必须匹配。固定读出(直接用聚合值作预测)时,对角线上误差为 0,非对角线落在 1.6–5.1 之间。注意这只说明「聚合编码了假设」,不说明某种聚合普遍更差——若下游有可学习的读出层,知道度数时求和可以精确还原均值。 | Aggregation must match the task assumption. With a fixed readout (the aggregate is the prediction), the diagonal has zero error and off-diagonal entries lie between 1.6 and 5.1. This shows that aggregation encodes an assumption; it does not show that any aggregation is universally worse — with a learnable readout and known degree, a sum reconstructs the mean exactly.

读法很直接:每一行里只有一根柱子的高度是 0。没有普适最优的聚合,只有和任务假设匹配的聚合。这解释了两件看起来矛盾的事:GIN 证明求和最有表达力(因为它能区分不同的多重集,而均值会丢掉度数),但这个实验里均值在自己的任务上误差为 0。两者都对——表达力是关于”能表示什么”,误差是关于”在这个任务上表示得好不好”。

Read it directly: in each row exactly one bar has height zero. There is no universally best aggregation, only one that matches the task assumption. This reconciles two apparently contradictory facts: GIN proves that sum is the most expressive aggregation (it distinguishes multisets, whereas the mean erases the degree), yet in this experiment the mean has zero error on its own task. Both are right — expressiveness is about what can be represented; error is about how well it is represented on this task.

6.4 什么才算有意义的创新 / What counts as a meaningful contribution

把上面的讨论收成一份清单。一个新的算子要成立,至少得在下面几项里站住一项,并且在其余项上不塌:

Condense the discussion into a checklist. For a new operator to stand, it must win on at least one of these and not collapse on the others:

  • 新的数学运算或参数化:确实定义了一个之前没有的映射,而不仅是重命名。
    A genuinely new operation or parameterisation: a mapping that did not exist before, not a renaming.
  • 不同的归纳偏置:假设改变了,而且这个新假设与目标任务的结构相符。
    A different inductive bias: the assumption changed, and the new assumption matches the structure of the target task.
  • 更合理的信息传播:多跳、异质、带符号、带方向的信息如何汇合,有了明确的机制而不是靠加深层数硬凑。
    More sensible information propagation: a mechanism for multi-hop, heterogeneous, signed or directed information instead of brute-force depth.
  • 更高的计算效率:单位效果的成本下降,且这种下降来自结构而不是实现技巧。
    Higher efficiency: lower cost per unit of performance, and the gain comes from structure rather than implementation tricks.
  • 更好的预测性能:在同参数、同数据、同预算下比较,并且报告方差与训练成本。
    Better predictive performance: compared at equal parameters, data and budget, with variance and training cost reported.
  • 更可靠的泛化:跨数据集、跨规模(序列更长、图更大、物种不同)仍然成立。
    More reliable generalisation: it survives across datasets and across scale — longer sequences, larger graphs, different species.

验证的顺序也有讲究,顺序错了会得出假阳性:先做理论分析(表达力、等变性、复杂度),再做受控消融(一次只改一处,超参数各自调好),最后才是benchmark(多个任务、多个随机种子、报告均值与标准差)。只做最后一步,几乎总能找到某个数据集上更好看的数字。

The order of validation matters; getting it wrong produces false positives. Start with theory (expressiveness, equivariance, complexity), then a controlled ablation (one change at a time, each with its own tuned hyperparameters), and only then a benchmark (several tasks, several seeds, mean and standard deviation). Skip to the last step alone and you will almost always find some dataset where the numbers look better.

还有一条常被忽略的:负结果也要报。一个算子如果在某些条件下更差,说清楚这些条件比藏起来更有价值——它恰好告诉后来者这个算子的边界在哪里。

One more thing is routinely omitted: report the negative results. Stating the conditions under which an operator is worse is more valuable than hiding them — it tells the next person exactly where the boundary lies.

7. AI 虚拟细胞需要什么样的算子? / What operators does a virtual cell need?

前面六章的例子大多来自图像和语言。生物学的问题不太一样:数据更稀疏、噪声更大、可做的干预更贵,而且因果关系藏在结构里。这一节把算子放回生物学的语境里看。

Most examples so far came from images and language. Biology differs: the data are sparser and noisier, interventions are expensive, and causality lives in the structure. This section puts operators back into a biological setting.

7.1 生物学问题长什么样 / What the biological problem looks like

以 Perturb-seq 为例:用 CRISPR 敲掉一个基因,然后用单细胞 RNA 测序读出成千上万个细胞的全转录组。得到的是”扰动 → 表达谱变化”的配对数据。想做的事通常是预测没做过的扰动,而不是记住做过的那些。

Take Perturb-seq: knock out a gene with CRISPR, then read the transcriptome of thousands of single cells. The result is paired data of “perturbation → expression change”. The usual goal is to predict perturbations that were never performed, not to memorise the ones that were.

这件事难在三点,每一点都直接指向算子的选择:

Three things make it hard, and each points straight at a choice of operator:

  • 稀疏且高维:一个细胞里多数基因的计数是 0,信噪比很低。归一化和聚合方式会显著影响结果。
    Sparse and high-dimensional: most genes in a cell have zero counts and the signal-to-noise ratio is low. Normalisation and aggregation matter a lot.
  • 结构先验是已知的:基因不是独立变量,调控关系、通路成员、蛋白互作都是已知知识。用不用、怎么用,是归纳偏置的问题。
    Structural priors exist: genes are not independent; regulatory relations, pathway membership and protein interactions are known. Whether and how to use them is a question of inductive bias.
  • 干预有方向、有符号:激活和抑制是两件事,敲除和过表达也是两件事。把它们压成同一种边,会丢掉关键信息。
    Interventions have direction and sign: activation and inhibition differ, and so do knockout and overexpression. Collapsing them into one kind of edge discards the key information.

7.2 一个 5 个基因的调控网络 / A five-gene regulatory network

下面的网络取自教科书里最经典的 p53 通路骨架,只保留 5 个基因和 6 条边。边的符号不是装饰:TP53 转录激活 MDM2,MDM2 反过来抑制 TP53(负反馈),TP53 激活 CDKN1A 与 BAX,MYC 抑制 CDKN1A 与 BAX。

The network below is the textbook p53 backbone, trimmed to five genes and six edges. The signs are not decoration: TP53 transcriptionally activates MDM2, MDM2 inhibits TP53 in return (negative feedback), TP53 activates CDKN1A and BAX, and MYC inhibits CDKN1A and BAX.

Code
g_genes <- data.frame(
  id = c("TP53", "MDM2", "CDKN1A", "BAX", "MYC"),
  x  = c(0, 1.55, 0.62, 2.32, -1.55),
  y  = c(0.95, 1.85, -1.10, -1.28, 0.45)
)
g_edges <- data.frame(
  from = c("TP53", "MDM2", "TP53", "TP53", "MYC", "MYC"),
  to   = c("MDM2", "TP53", "CDKN1A", "BAX", "CDKN1A", "BAX"),
  sgn  = c(1, -1, 1, 1, -1, -1),
  crv  = c(-0.45, 0, 0, 0, 0, 0)
)
g_edges$x    <- g_genes$x[match(g_edges$from, g_genes$id)]
g_edges$y    <- g_genes$y[match(g_edges$from, g_genes$id)]
g_edges$xend <- g_genes$x[match(g_edges$to,   g_genes$id)]
g_edges$yend <- g_genes$y[match(g_edges$to,   g_genes$id)]
dx <- g_edges$xend - g_edges$x; dy <- g_edges$yend - g_edges$y
dd <- sqrt(dx^2 + dy^2)
g_edges$x    <- g_edges$x    + 0.34 * dx / dd
g_edges$y    <- g_edges$y    + 0.34 * dy / dd
g_edges$xend <- g_edges$xend - 0.34 * dx / dd
g_edges$yend <- g_edges$yend - 0.34 * dy / dd

p_grn <- ggplot() +
  # activation: TP53 -> MDM2, bowed one way
  geom_curve(data = g_edges[g_edges$crv != 0 & g_edges$sgn > 0, ],
             aes(x, y, xend = xend, yend = yend),
             curvature = -0.42, colour = op_pal[["blue"]], linewidth = 0.6,
             arrow = arrow_op) +
  geom_segment(data = g_edges[g_edges$crv == 0 & g_edges$sgn > 0, ],
               aes(x, y, xend = xend, yend = yend),
               colour = op_pal[["blue"]], linewidth = 0.6, arrow = arrow_op) +
  # inhibition: MDM2 -> TP53 bowed the other way, plus MYC -> CDKN1A / BAX
  geom_curve(data = g_edges[g_edges$from == "MDM2", ],
             aes(x = xend, y = yend, xend = x, yend = y),
             curvature = 0.42, colour = op_pal[["coral"]], linewidth = 0.6,
             arrow = arrow_op, linetype = "22") +
  geom_segment(data = g_edges[g_edges$crv == 0 & g_edges$sgn < 0, ],
               aes(x, y, xend = xend, yend = yend),
               colour = op_pal[["coral"]], linewidth = 0.6,
               arrow = arrow_op, linetype = "22") +
  geom_point(data = g_genes, aes(x, y), size = 17, shape = 21,
             fill = op_pal[["pale"]], colour = op_pal[["ink"]], stroke = 0.45) +
  geom_point(data = g_genes[g_genes$id == "TP53", ], aes(x, y), size = 19.5,
             shape = 21, fill = NA, colour = op_pal[["grey"]], stroke = 0.7) +
  geom_text(data = g_genes, aes(x, y, label = id), size = 2.6) +
  annotate("text", x = -0.45, y = 0.95, label = "KO", size = 2.9, hjust = 1,
           colour = op_pal[["grey"]]) +
  coord_cartesian(xlim = c(-2.35, 2.95), ylim = c(-1.85, 2.25)) +
  labs(title = "p53 regulatory backbone") + plain

p_grn
Figure 19: 一个 5 基因的调控网络。实线蓝箭头表示激活(TP53→MDM2、TP53→CDKN1A、TP53→BAX),虚线珊瑚线表示抑制(MDM2⊣TP53、MYC⊣CDKN1A、MYC⊣BAX)。TP53 与 MDM2 构成负反馈环,这是整张网络里最需要「关系感知」的一处:任何把它们当成对称边的模型都会算错稳态。灰色圈标出下文被扰动的基因。 | A five-gene regulatory network. Solid blue arrows are activations (TP53→MDM2, TP53→CDKN1A, TP53→BAX) and dashed coral lines are inhibitions (MDM2⊣TP53, MYC⊣CDKN1A, MYC⊣BAX). TP53 and MDM2 form a negative-feedback loop — the place where relation-awareness matters most: any model that treats them as symmetric edges gets the steady state wrong. The grey ring marks the gene perturbed below.

这里每个符号都会在计算里用到。把 \(-0.7\) 取成绝对值,“MYC 抑制 CDKN1A”就变成了”MYC 激活 CDKN1A”,过表达 MYC 的预测方向会整个翻过来——7.3 节会把这个例子算出来。

Every sign here enters the computation. Take the absolute value of \(-0.7\) and “MYC inhibits CDKN1A” becomes “MYC activates CDKN1A”, which flips the predicted direction of MYC overexpression entirely — section 7.3 works that example through.

7.3 扰动如何传播:符号在哪一步显形 / How a perturbation propagates, and where the sign shows up

用一个最小的线性动力学模型表示细胞状态。表达向量 \(h_t\) 每一步受两个因素影响:自身的衰减,以及上游基因的调控。

Use a minimal linear dynamical model for cell state. At each step the expression vector \(h_t\) is driven by two things: its own decay and regulation from upstream genes.

\[h_{t+1}=(1-\alpha)\,h_t+\alpha\,\big(W h_t + b\big)\]

\(W\) 的第 \(i\) 行第 \(j\) 列是”\(j\) 调控 \(i\)“的强度,带符号;\(b\) 是基础转录水平,\(\alpha\) 控制更新速度。稳态满足 \(h^\ast=(I-W)^{-1}b\)。敲除等价于每一步把那个分量钳在 0,过表达等价于钳在一个更高的值——两者都是”对某个坐标施加外部约束”,不需要改 \(W\) 本身。这个模型比 \(\tanh\) 版本更朴素,但它把符号的作用暴露得最清楚。

The \((i,j)\) entry of \(W\) is the signed strength with which \(j\) regulates \(i\); \(b\) is basal transcription and \(\alpha\) sets the update rate. The steady state satisfies \(h^\ast=(I-W)^{-1}b\). A knockout clamps that component to 0 at every step, and overexpression clamps it higher — both are external constraints on one coordinate, with \(W\) untouched. This model is plainer than the \(\tanh\) version, which is exactly why it exposes what the sign does.

下面的实验把 MYC 钳到 2.0(过表达),分别在两种模型里跑:一种是带符号的 \(W\),另一种把两条抑制边的符号去掉。

The experiment below clamps MYC at 2.0 (overexpression) and runs it in two models: one with the signed \(W\), and one where the two inhibitory edges have lost their sign.

Code
genes <- c("TP53", "MDM2", "CDKN1A", "BAX", "MYC")
W <- matrix(0, 5, 5, dimnames = list(genes, genes))
W["MDM2",   "TP53"] <-  1.0
W["TP53",   "MDM2"] <- -0.8
W["CDKN1A", "TP53"] <-  1.2
W["BAX",    "TP53"] <-  0.9
W["CDKN1A", "MYC"]  <- -0.7
W["BAX",    "MYC"]  <- -0.3
b <- c(TP53 = 1.2, MDM2 = 1.0, CDKN1A = 1.25, BAX = 0.9, MYC = 1.0)
alpha <- 0.4

run <- function(Wm, clamp = NULL, steps = 400) {
  h <- b
  for (i in seq_len(steps)) {
    h <- (1 - alpha) * h + alpha * (as.vector(Wm %*% h) + b)
    h[h < 0] <- 0
    if (!is.null(clamp)) h[names(clamp)] <- clamp
  }
  h
}

W_blind <- W                                    # lose the sign of inhibition only
W_blind[c("CDKN1A", "BAX"), c("MYC")] <- abs(W[c("CDKN1A", "BAX"), c("MYC")])

st <- rbind(
  data.frame(model = "signed W",  gene = genes,
             base = run(W)[genes], pert = run(W, c(MYC = 2.0))[genes]),
  data.frame(model = "sign-blind", gene = genes,
             base = run(W_blind)[genes], pert = run(W_blind, c(MYC = 2.0))[genes])
)
st$gene <- factor(st$gene, levels = rev(genes))
st$model <- factor(st$model, levels = c("signed W", "sign-blind"))
st$dir <- ifelse(st$pert > st$base, "up", "down")

ggplot(st, aes(y = gene)) +
  geom_segment(aes(x = base, xend = pert, yend = gene, colour = dir),
               arrow = grid::arrow(length = grid::unit(0.075, "inches"), type = "closed"),
               linewidth = 0.65) +
  geom_point(aes(x = base), colour = op_pal[["grey"]], size = 2.2) +
  geom_point(aes(x = pert), colour = op_pal[["blue"]], size = 2.6) +
  geom_text(aes(x = pmax(base, pert), label = sprintf("%.2f", pert)),
            vjust = -0.85, size = 2.5, colour = op_pal[["blue"]], hjust = 0.3) +
  scale_colour_manual(values = c(up = op_pal[["orange"]], down = op_pal[["coral"]]),
                      guide = "none") +
  scale_x_continuous(limits = c(0, 3.35), expand = expansion(mult = c(0.01, 0.02))) +
  facet_wrap(~ model) +
  labs(x = "steady-state expression", y = NULL) +
  theme(axis.text.y = element_text(size = 9))
Figure 20: MYC 过表达的稳态预测。每个面板是一个模型:实心点是基线,箭头指向过表达后的稳态。带符号的模型预测 CDKN1A 与 BAX 下降;把两条抑制边的符号去掉之后,同样的网络预测它们上升。TP53 与 MDM2 在两个面板里都不动,因为从 MYC 出发没有到达它们的路径。 | Predicted steady state under MYC overexpression. Each panel is one model: the solid dot is baseline and the arrow points to the overexpressed steady state. With signs, CDKN1A and BAX fall; after dropping the sign on the two inhibitory edges the same network predicts they rise. TP53 and MDM2 do not move in either panel, because no path from MYC reaches them.

读数:带符号的模型给出 CDKN1A \(0.82\to0.12\)、BAX \(0.80\to0.50\),两个都下降;符号丢失的模型给出 \(2.22\to2.92\) 与 \(1.40\to1.70\),两个都上升。同一批参数、同一张图,方向完全相反,差别只在于”抑制”这个概念有没有在参数里占据一个位置。

Reading it: the signed model gives CDKN1A \(0.82\to0.12\) and BAX \(0.80\to0.50\), both falling; the sign-blind model gives \(2.22\to2.92\) and \(1.40\to1.70\), both rising. Same numbers, same graph, opposite directions — the only difference is whether “inhibition” had a place in the parameters.

这里有个反直觉的细节值得单独说:如果把扰动换成敲除 TP53,两种模型给出的变化量完全一样(CDKN1A 都是 \(-0.27\),BAX 都是 \(-0.20\))。原因是线性模型里未改动的 MYC 项在相减时抵消了,符号错误被完全藏起来。换句话说,符号错误只在扰动”符号被弄错的那个基因”时才会显形。 这正好解释了为什么只用少数几个扰动做 benchmark 很容易漏掉结构性错误:你测的那些扰动,可能恰好落在错误不显形的位置上。

One counter-intuitive detail deserves its own paragraph: switch the perturbation to a TP53 knockout and both models give identical changes (CDKN1A \(-0.27\), BAX \(-0.20\) in both). In a linear model the untouched MYC term cancels in the difference, so the sign error is completely hidden. In other words, a sign error only becomes visible when you perturb the very gene whose sign you got wrong. That is exactly why a benchmark built on a handful of perturbations easily misses structural errors: the perturbations you happened to test may sit where the error does not show.

这也是关系感知算子存在的理由。它不是一个更花哨的打分函数,而是唯一能让”抑制”这个概念在计算里有位置的写法;而它的价值要靠在正确的扰动实验上验证,否则连带符号的模型都能在你选的 benchmark 上打平。

That is the reason relation-aware operators exist. They are not a fancier scoring function but the only way for “inhibition” to occupy a place in the computation — and their value must be demonstrated on the right perturbation experiments, or a sign-blind model will tie with them on your benchmark.

7.4 非配对多组学:对齐是一个模块,不是一个算子 / Unpaired multi-omics: alignment is a module

真实数据里常常没有配对样本:一批细胞测了 RNA,另一批测了 ATAC,来自不同病人、不同批次。要把它们放在同一个空间里比较,需要对齐。

Real data often lack paired samples: one batch of cells has RNA, another has ATAC, from different patients and batches. Comparing them in one space requires alignment.

常见做法是学一个共享的嵌入:两个模态各自的编码器把细胞映射到同一空间,再用对比损失把”应该是同一个细胞状态”的样本拉近、其余推远。也有工作用最优传输(optimal transport)显式求一个跨模态的匹配方案。

The usual approach learns a shared embedding: an encoder per modality maps cells into one space, and a contrastive loss pulls together samples that should represent the same state while pushing the rest apart. Other work solves for an explicit cross-modality matching with optimal transport.

按第 5 章的分类,这类工作多半属于模块级:对比损失是一个训练目标,不是前向传播里的算子;最优传输求解是训练循环里的一步,也不进推理图。它们的价值可能非常大,但把它们称作”新算子”会掩盖真正起作用的机制——真正进入推理的,还是编码器里的线性层、注意力和归一化。

By the taxonomy of section 5, most of this work is module level: a contrastive loss is a training objective, not an operator in the forward pass; an optimal-transport solver is a step in the training loop and never enters the inference graph. The value can be enormous, but calling it a “new operator” hides the mechanism actually at work — what enters inference is still the linear layers, attention and normalisation inside the encoders.

7.5 先验该有多硬 / How hard should the prior be

同一个生物学网络,可以用三种强度塞进模型:

The same biological network can be injected at three strengths:

  • 硬先验:把调控边写死成邻接矩阵,模型只能在这些边上传递消息。省数据、可解释,但发现不了新机制——没被写进网络的调控关系永远不会被预测出来。
    Hard prior: bake the edges into the adjacency matrix; messages may only travel along them. Data-efficient and interpretable, but unable to discover new mechanisms — a regulation absent from the network can never be predicted.
  • 软先验:把已知边作为初始权重或额外的注意力偏置,允许模型偏离。既能利用知识,又保留余地。
    Soft prior: use known edges as initial weights or as an additive bias on attention, letting the model deviate. It exploits knowledge while leaving room.
  • 学出来的结构:从数据里推断图(例如用基因共表达或注意力权重反推),先验只体现在”稀疏”“低秩”这类约束上。数据需求大,但可能发现未记录的调控。
    Learned structure: infer the graph from data (say, from co-expression or attention weights), with the prior living only in constraints such as sparsity or low rank. Data-hungry, but able to surface undocumented regulation.

三种选择没有对错,只有与问题的匹配度。预测已知通路上某个基因的敲除效应,硬先验通常最划算;寻找新的调控因子,硬先验则是自我设限。现成的虚拟细胞模型各自站在不同的位置:GEARS 用基因本体图做归纳偏置来预测扰动,scGPT 与 Geneformer 则在大量单细胞语料上预训练、再用少量扰动数据微调。它们的差别不在”谁的算子更先进”,而在于把多少生物学结构写进了计算。

None of the three is right or wrong; only the match to the question matters. For predicting a knockout inside a known pathway, a hard prior is usually the best value; for finding new regulators it is self-imposed blindness. Existing virtual-cell models sit at different points: GEARS uses gene-ontology graphs as an inductive bias for perturbation prediction, while scGPT and Geneformer pretrain on large single-cell corpora and fine-tune on smaller perturbation sets. Their difference is not “whose operator is more advanced” but how much biological structure is written into the computation.

8. 总结:算子、架构与能力 / Operators, architectures and capability

八章下来,“算子”这个词被拆成了好几层。最后用一张图把它们叠回去,顺序是从下往上:越往下越抽象、越接近定义;越往上越具体、越接近产品。

Eight sections have split the word “operator” into several layers. One figure stacks them back together, bottom to top: the lower you go, the more abstract and definitional; the higher, the more concrete and product-like.

Code
hier <- data.frame(
  y = 7:1,
  name = c("7  系统  system", "6  架构  architecture", "5  模块  module",
           "4  层  layer", "3  框架算子  framework op", "2  GPU kernel",
           "1  数学映射  mathematical map"),
  ex = c("预测服务、虚拟细胞平台 / a service, a virtual-cell platform",
         "GPT、ViT、RGAT / GPT, ViT, RGAT",
         "Transformer block、多头注意力 / a transformer block, MHA",
         "nn.Linear、nn.MultiheadAttention",
         "torch.matmul、F.softmax、autograd",
         "cuBLAS、Triton、FlashAttention",
         "y = Wx + b、softmax、conv"),
  mean = c("数据闭环与部署 / data loop, deployment",
           "层怎么拼、邻接假设 / wiring, adjacency assumption",
           "多个层的固定组合 / a fixed bundle of layers",
           "参数 + 前向 + 反向 / params, forward, backward",
           "数学 + 自动微分 / maths + autodiff",
           "线程块、共享内存、访存 / blocks, SRAM, traffic",
           "定义本身 / the definition"),
  grp = c("sys", "arch", "arch", "arch", "fw", "ker", "math")
)
hier$fill <- c(sys = "#FBE3D9", arch = "#FDF0D8", fw = "#DCE8F5",
               ker = "#D8EDE7", math = "#EAF0F5")[hier$grp]
hier$bar  <- c(sys = op_pal[["coral"]], arch = op_pal[["orange"]], fw = op_pal[["blue"]],
               ker = op_pal[["teal"]], math = op_pal[["grey"]])[hier$grp]

ggplot(hier) +
  geom_rect(aes(xmin = 0, xmax = 10, ymin = y - 0.4, ymax = y + 0.4), fill = hier$fill) +
  geom_rect(aes(xmin = 0, xmax = 0.16, ymin = y - 0.4, ymax = y + 0.4),
            fill = hier$bar, colour = NA) +
  geom_text(aes(0.42, y, label = name), hjust = 0, size = 3.1, fontface = "bold",
            colour = op_pal[["ink"]]) +
  geom_text(aes(3.55, y, label = ex), hjust = 0, size = 2.5,
            colour = op_pal[["blue"]]) +
  geom_text(aes(7.05, y, label = mean), hjust = 0, size = 2.5,
            colour = op_pal[["ink"]]) +
  annotate("text", x = 3.55, y = 7.72, label = "example", size = 2.4,
           colour = op_pal[["grey"]], hjust = 0) +
  annotate("text", x = 7.05, y = 7.72, label = "changing this layer =", size = 2.4,
           colour = op_pal[["grey"]], hjust = 0) +
  coord_cartesian(xlim = c(0, 10.6), ylim = c(0.45, 7.95)) +
  plain
Figure 21: 算子的七层结构。第 1–2 层回答「做什么」,第 3 层回答「怎么在芯片上做」,第 4–6 层回答「怎么搭」,第 7 层是最终交付的系统。每往上一层,可替换的部件变多、单次改动的影响变大,但都不改变下层已经确定的数学。 | Seven layers. Layers 1–2 answer 「what is done」, layer 3 answers 「how it is done on silicon」, layers 4–6 answer 「how it is assembled」, and layer 7 is the delivered system. Going up, more parts become swappable and a single change has wider impact — but none of it changes the mathematics already fixed below.

这张图最实用的地方,是把它当成”定位器”用。听到一句关于算子的话,先问它落在第几层:

The most practical use of this figure is as a locator. When you hear a claim about an operator, first ask which layer it lives on:

  • “我们提出了新的注意力机制” → 第 1–2 层(换数学)。
    “We propose a new attention mechanism” → layers 1–2 (new mathematics).
  • “我们把注意力的显存降了 10 倍” → 第 3 层(换 kernel,数学不变)。
    “We cut attention’s memory 10x” → layer 3 (new kernel, same mathematics).
  • “我们把 LayerNorm 移到注意力之前” → 第 4–6 层(换拼装)。
    “We moved LayerNorm before attention” → layers 4–6 (new wiring).
  • “我们的模型在细胞扰动预测上更强” → 第 7 层(系统级结果),需要回头找原因。
    “Our model predicts perturbations better” → layer 7 (a system-level result) that needs its cause traced back down.

三层关系可以压成一句话:算子决定了能表达什么函数、以及表达它的成本;架构决定了这些函数怎么组合起来匹配数据的结构;能力是这两者与数据、规模共同作用的结果,从来不是某一个算子单独提供的。

The relationship compresses into one sentence: operators fix which functions can be expressed and at what cost; architectures fix how those functions are composed to match the structure of the data; capability is the joint outcome of both plus data and scale, never something a single operator supplies on its own.

8.1 听到”更好的算子”时该问什么 / Questions to ask about a “better operator”

把全篇的判据收成八问。前四问用来定位,后四问用来验证:

Collect the criteria into eight questions. The first four locate the claim; the last four test it:

  1. 它改的是哪一层?能不能给出改动前后两个公式?如果给不出,多半是第 3 层或第 6 层。
    Which layer does it change? Can it show the formula before and after? If not, it is probably layer 3 or layer 6.
  2. 输出与谁等价?若与标准实现数值等价,就不能用”效果更好”作为卖点,只能比时间、显存、能耗。
    What is its output equivalent to? If numerically equal to a standard implementation, “better results” is not a selling point; only time, memory and energy are.
  3. 参数量、FLOPs、显存、算术强度各是多少?落在 roofline 的斜线一侧还是水平段?
    What are the parameter count, FLOPs, memory and arithmetic intensity? Which side of the roofline?
  4. 它假设了什么结构?这个假设在你的数据上成立吗?反过来,在什么数据上必然失效?
    What structure does it assume? Does that hold for your data — and on what data must it fail?
  5. 消融是否只改了这一处?新旧两版的超参数是否各自调过?随机种子有几个,方差多大?
    Does the ablation change only this one thing? Were both versions tuned separately? How many seeds, and what is the variance?
  6. 是否同参数、同数据、同训练预算?把一个算子塞进三倍大的模型里比较,得到的不是算子的功劳。
    Equal parameters, data and training budget? Putting an operator inside a 3x larger model is not evidence for the operator.
  7. 规模变了还成立吗?序列更长、图更大、物种不同、批次不同,结论是否保持?
    Does it survive scale — longer sequences, larger graphs, different species, different batches?
  8. 负结果在哪里?作者有没有说清楚它在什么条件下更差?一条都没有的论文,通常不是因为它处处更好。
    Where are the negative results? Does the paper say when it is worse? A paper with none usually is not better everywhere.

这八问不需要任何代码就能问出口,但它们能把大多数宣传语还原成可检验的陈述。回到文章开头的 \(2\times3=6\):从那一行到今天的虚拟细胞,中间跨过的是同一件事——把”怎么把输入变成输出”写清楚,并且诚实地说明这个写法在哪里成立。

These eight questions need no code to ask, yet they turn most marketing into testable statements. Back to \(2\times3=6\) from the opening: from that line to today’s virtual cells, the thread is the same — write down clearly how inputs become outputs, and say honestly where that writing holds.

参考文献 / References

  1. Vaswani, A., et al. (2017). Attention is all you need. NeurIPS. arXiv:1706.03762
  2. Kipf, T. N. & Welling, M. (2017). Semi-supervised classification with graph convolutional networks. ICLR. arXiv:1609.02907
  3. Veličković, P., et al. (2018). Graph attention networks. ICLR. arXiv:1710.10903
  4. Xu, K., et al. (2019). How powerful are graph neural networks? ICLR. arXiv:1810.00826
  5. Gilmer, J., et al. (2017). Neural message passing for quantum chemistry. ICML. arXiv:1704.01212
  6. Schlichtkrull, M., et al. (2018). Modeling relational data with graph convolutional networks. ESWC. arXiv:1703.06103
  7. Busbridge, D., et al. (2019). Relational graph attention networks. arXiv:1904.05811
  8. Dao, T., et al. (2022). FlashAttention: fast and memory-efficient exact attention with IO-awareness. NeurIPS. arXiv:2205.14135
  9. Katharopoulos, A., et al. (2020). Transformers are RNNs: fast autoregressive transformers with linear attention. ICML. arXiv:2006.16236
  10. Assran, M., et al. (2023). Self-supervised learning from images with a joint-embedding predictive architecture. CVPR. arXiv:2301.08243
  11. He, K., et al. (2016). Deep residual learning for image recognition. CVPR. arXiv:1512.03385
  12. Shazeer, N., et al. (2017). Outrageously large neural networks: the sparsely-gated mixture-of-experts layer. ICLR. arXiv:1701.06538
  13. Chen, T., et al. (2018). TVM: an automated end-to-end optimizing compiler for deep learning. OSDI. arXiv:1802.04799
  14. Williams, S., Waterman, A. & Patterson, D. (2009). Roofline: an insightful visual performance model for multicore architectures. Communications of the ACM 52(4), 65–76.
  15. Theodoris, C. V., et al. (2023). Transfer learning enables predictions in network biology. Nature 618, 616–624. doi:10.1038/s41586-023-06139-9
  16. Cui, H., et al. (2024). scGPT: toward building a foundation model for single-cell multi-omics using generative AI. Nature Methods 21, 1470–1480.
  17. Roohani, Y., et al. (2024). Predicting transcriptional outcomes of novel multigene perturbations with GEARS. Nature Biotechnology 42, 927–936. arXiv:2304.12783
  18. Replogle, J. M., et al. (2022). Mapping information-rich genotype–phenotype landscapes with genome-scale Perturb-seq. Cell 185(14), 2559–2575.
  19. PyTorch documentation. torch.matmul, torch.nn.functional, and the ATen operator registry. https://pytorch.org/docs/stable/
  20. Dosovitskiy, A., et al. (2021). An image is worth 16x16 words: transformers for image recognition at scale. ICLR. arXiv:2010.11929
  21. Isensee, F., et al. (2021). nnU-Net: a self-configuring method for deep learning-based biomedical image segmentation. Nature Methods 18, 203–211.
  22. Avsec, Ž., et al. (2021). Effective gene expression prediction from sequence by integrating long-range interactions. Nature Methods 18, 1196–1203.
  23. Lin, Z., et al. (2023). Evolutionary-scale prediction of atomic-level protein structure with a language model. Science 379, 1123–1130.
  24. Himmelstein, D. S., et al. (2017). Systematic integration of biomedical knowledge prioritizes drugs for repurposing. eLife 6, e26726.

引用说明:第 1–13、17、20 条给出 arXiv 编号,正式发表信息以期刊页面为准;第 14–16、18、21–24 条给出卷页,未标 DOI 的条目建议在引用前核对期刊记录;第 19 条为官方文档。

Citation note: entries 1–13, 17 and 20 give arXiv identifiers; check the journal record for the version of record. Entries 14–16, 18 and 21–24 give volume and pages; verify against the journal before citing where no DOI is given. Entry 19 is official documentation.